mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12f2a21bda | |||
| 20769ea041 | |||
| fd984774d0 | |||
| e216c68f6d | |||
| 14102de673 | |||
| d876bf99fb | |||
| d29bae9a14 | |||
| d0c5ec2f39 | |||
| 2e2a8bcc94 | |||
| 245d14f905 | |||
| 8717d160ce | |||
| 587ba85cc9 | |||
| 80bb1ce977 | |||
| c598c61afe | |||
| 68b07eb466 | |||
| 06a149407a | |||
| b9387734c7 | |||
| 810b7908e4 | |||
| 6b76a898fa | |||
| 8ba83e2173 | |||
| 5a4b8c5faa | |||
| 3f8bdf7ac2 | |||
| 9c4b4f4989 | |||
| c5d534b2ad | |||
| bed3d1078e | |||
| 83e87254c6 | |||
| f576ddccfe | |||
| 0a674eacfa | |||
| ceac57bd64 | |||
| 97f31e3f2a | |||
| a06671e8ce | |||
| fd3c21d28b | |||
| c0c36bacbb | |||
| 7bd1ea7c40 | |||
| 5ebb1e8a61 | |||
| 96d6987858 | |||
| 3602198cd8 | |||
| cffe5c0ec6 | |||
| 00bab31cff | |||
| 3ef2b7cf42 | |||
| 18743da2fc | |||
| fe1307d7e7 | |||
| a226889143 | |||
| 347f033802 | |||
| 4d294c8cbe | |||
| f52634139c | |||
| 0790358fb3 | |||
| 760b1041bc | |||
| 2f0f966665 | |||
| 62a894ca66 | |||
| 29ec5f5a46 | |||
| 11f2b8a53f | |||
| a5f593d68d | |||
| b48498d07f |
@@ -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} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
@@ -14,13 +15,15 @@ import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input from "~/components/Input";
|
||||
import InputSelectPermission from "~/components/InputSelectPermission";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
import Switch from "~/components/Switch";
|
||||
import Text from "~/components/Text";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { EmptySelectValue } from "~/types";
|
||||
|
||||
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
|
||||
const IconPicker = createLazyComponent(() => import("~/components/IconPicker"));
|
||||
|
||||
export interface FormData {
|
||||
name: string;
|
||||
@@ -30,6 +33,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 +65,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 {
|
||||
@@ -70,6 +89,11 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
|
||||
const values = watch();
|
||||
|
||||
// Preload the IconPicker component on mount
|
||||
React.useEffect(() => {
|
||||
void IconPicker.preload();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
// If the user hasn't picked an icon yet, go ahead and suggest one based on
|
||||
// the name of the collection. It's the little things sometimes.
|
||||
@@ -184,7 +208,7 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
);
|
||||
});
|
||||
|
||||
const StyledIconPicker = styled(IconPicker)`
|
||||
const StyledIconPicker = styled(IconPicker.Component)`
|
||||
margin-left: 4px;
|
||||
margin-right: 4px;
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as React from "react";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
export interface LazyComponent<T extends React.ComponentType<any>> {
|
||||
Component: React.LazyExoticComponent<T>;
|
||||
preload: () => Promise<{ default: T }>;
|
||||
}
|
||||
|
||||
interface LazyLoadOptions {
|
||||
retries?: number;
|
||||
interval?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a lazy-loaded component with preloading capability and automatic retries on failure.
|
||||
*
|
||||
* @param factory A function that returns a promise of a component (eg: () => import('./MyComponent'))
|
||||
* @param options Optional configuration for retry behavior
|
||||
* @returns An object containing the lazy Component and a preload function
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const MyComponent = createLazyComponent(() => import('./MyComponent'));
|
||||
*
|
||||
* function App() {
|
||||
* return (
|
||||
* <Suspense fallback={<div>Loading...</div>}>
|
||||
* <MyComponent.Component />
|
||||
* </Suspense>
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* // Preload when needed:
|
||||
* MyComponent.preload();
|
||||
* ```
|
||||
*/
|
||||
export function createLazyComponent<T extends React.ComponentType<any>>(
|
||||
factory: () => Promise<{ default: T }>,
|
||||
options: LazyLoadOptions = {}
|
||||
): LazyComponent<T> {
|
||||
const { retries, interval } = options;
|
||||
|
||||
return {
|
||||
Component: lazyWithRetry(factory, retries, interval),
|
||||
preload: factory,
|
||||
};
|
||||
}
|
||||
@@ -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} />;
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { PopoverDisclosure, usePopoverState } from "reakit";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import Flex from "~/components/Flex";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
import Popover from "~/components/Popover";
|
||||
@@ -12,7 +13,7 @@ import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import useWindowSize from "~/hooks/useWindowSize";
|
||||
import Tooltip from "../Tooltip";
|
||||
|
||||
const EmojiPanel = React.lazy(
|
||||
const EmojiPanel = createLazyComponent(
|
||||
() => import("~/components/IconPicker/components/EmojiPanel")
|
||||
);
|
||||
|
||||
@@ -104,6 +105,7 @@ const ReactionPicker: React.FC<Props> = ({
|
||||
aria-label={t("Reaction picker")}
|
||||
className={className}
|
||||
onClick={handlePopoverButtonClick}
|
||||
onMouseEnter={() => EmojiPanel.preload()}
|
||||
size={size}
|
||||
>
|
||||
<ReactionIcon size={22} />
|
||||
@@ -123,7 +125,7 @@ const ReactionPicker: React.FC<Props> = ({
|
||||
{popover.visible && (
|
||||
<React.Suspense fallback={<Placeholder />}>
|
||||
<EventBoundary>
|
||||
<EmojiPanel
|
||||
<EmojiPanel.Component
|
||||
height={300}
|
||||
panelWidth={panelWidth}
|
||||
query={query}
|
||||
|
||||
@@ -93,11 +93,13 @@ export const Suggestions = observer(
|
||||
const suggestions = React.useMemo(() => {
|
||||
const filtered: Suggestion[] = (
|
||||
document
|
||||
? users.notInDocument(document.id, query)
|
||||
? users
|
||||
.notInDocument(document.id, query)
|
||||
.filter((u) => u.id !== user.id)
|
||||
: collection
|
||||
? users.notInCollection(collection.id, query)
|
||||
: users.activeOrInvited
|
||||
).filter((u) => !u.isSuspended && u.id !== user.id);
|
||||
).filter((u) => !u.isSuspended);
|
||||
|
||||
if (isEmail(query)) {
|
||||
filtered.push(getSuggestionForEmail(query));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -27,7 +27,12 @@ function SettingsSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const configs = useSettingsConfig();
|
||||
let configs = useSettingsConfig();
|
||||
|
||||
configs = configs.filter((item) =>
|
||||
"isActive" in item ? item.isActive : true
|
||||
);
|
||||
|
||||
const groupedConfig = groupBy(configs, "group");
|
||||
|
||||
const returnToApp = React.useCallback(() => {
|
||||
@@ -63,8 +68,9 @@ function SettingsSidebar() {
|
||||
<SidebarLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClickIntent={item.preload}
|
||||
active={
|
||||
item.path !== settingsPath()
|
||||
item.path.startsWith(settingsPath("templates"))
|
||||
? location.pathname.startsWith(item.path)
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -321,6 +321,7 @@ const Container = styled(Flex)<ContainerProps>`
|
||||
z-index: ${depths.mobileSidebar};
|
||||
max-width: 80%;
|
||||
min-width: 280px;
|
||||
padding-left: var(--sal);
|
||||
${fadeOnDesktopBackgrounded()}
|
||||
|
||||
@media print {
|
||||
|
||||
@@ -38,10 +38,10 @@ function StarredLink({ star }: Props) {
|
||||
const { ui, collections, documents } = useStores();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const { documentId, collectionId } = star;
|
||||
const collection = collections.get(collectionId);
|
||||
const collection = collectionId ? collections.get(collectionId) : undefined;
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const sidebarContext = starredSidebarContext(
|
||||
star.documentId ?? star.collectionId
|
||||
star.documentId ?? star.collectionId ?? ""
|
||||
);
|
||||
const [expanded, setExpanded] = useState(
|
||||
(star.documentId
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -41,6 +41,7 @@ function useKeyboardShortcuts({
|
||||
useKeyDown(
|
||||
(ev) =>
|
||||
isModKey(ev) &&
|
||||
!popover.visible &&
|
||||
ev.code === "KeyF" &&
|
||||
// Keyboard handler is through the AppMenu on Desktop v1.2.0+
|
||||
!(Desktop.bridge && "onFindInPage" in Desktop.bridge),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { isCode } from "@shared/editor/lib/isCode";
|
||||
import { findParentNode } from "@shared/editor/queries/findParentNode";
|
||||
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { getSafeAreaInsets } from "@shared/utils/browser";
|
||||
import { HEADER_HEIGHT } from "~/components/Header";
|
||||
import { Portal } from "~/components/Portal";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
@@ -241,12 +242,16 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
|
||||
|
||||
if (props.active) {
|
||||
const rect = document.body.getBoundingClientRect();
|
||||
const safeAreaInsets = getSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ReactPortal>
|
||||
<MobileWrapper
|
||||
ref={menuRef}
|
||||
style={{
|
||||
bottom: `calc(100% - ${height - rect.y}px)`,
|
||||
bottom: `calc(100% - ${
|
||||
height - rect.y - safeAreaInsets.bottom
|
||||
}px)`,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
|
||||
@@ -2,7 +2,8 @@ import Extension from "@shared/editor/lib/Extension";
|
||||
import { InputRule } from "@shared/editor/lib/InputRule";
|
||||
|
||||
const rightArrow = new InputRule(/->$/, "→");
|
||||
const emdash = new InputRule(/--$/, "—");
|
||||
// Note that the suppression of pipe here prevents conflict with table creation rule.
|
||||
const emdash = new InputRule(/(?:^|[^\|])(--)$/, "—");
|
||||
const oneHalf = new InputRule(/(?:^|\s)(1\/2)$/, "½");
|
||||
const threeQuarters = new InputRule(/(?:^|\s)(3\/4)$/, "¾");
|
||||
const copyright = new InputRule(/\(c\)$/, "©️");
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function formattingMenuItems(
|
||||
shortcut: `${metaDisplay}+B`,
|
||||
icon: <BoldIcon />,
|
||||
active: isMarkActive(schema.marks.strong),
|
||||
visible: !isCode && (!isMobile || !isEmpty),
|
||||
visible: !isCodeBlock && (!isMobile || !isEmpty),
|
||||
},
|
||||
{
|
||||
name: "em",
|
||||
@@ -75,7 +75,7 @@ export default function formattingMenuItems(
|
||||
shortcut: `${metaDisplay}+I`,
|
||||
icon: <ItalicIcon />,
|
||||
active: isMarkActive(schema.marks.em),
|
||||
visible: !isCode && (!isMobile || !isEmpty),
|
||||
visible: !isCodeBlock && (!isMobile || !isEmpty),
|
||||
},
|
||||
{
|
||||
name: "strikethrough",
|
||||
@@ -83,7 +83,7 @@ export default function formattingMenuItems(
|
||||
shortcut: `${metaDisplay}+D`,
|
||||
icon: <StrikethroughIcon />,
|
||||
active: isMarkActive(schema.marks.strikethrough),
|
||||
visible: !isCode && (!isMobile || !isEmpty),
|
||||
visible: !isCodeBlock && (!isMobile || !isEmpty),
|
||||
},
|
||||
{
|
||||
tooltip: dictionary.mark,
|
||||
|
||||
@@ -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") || "{}");
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export default function useRequest<T = unknown>(
|
||||
if (makeRequestOnMount) {
|
||||
void request();
|
||||
}
|
||||
}, [request, makeRequestOnMount]);
|
||||
}, []);
|
||||
|
||||
return { data, loading, loaded, error, request };
|
||||
}
|
||||
|
||||
@@ -13,22 +13,27 @@ import {
|
||||
ImportIcon,
|
||||
ShapesIcon,
|
||||
Icon,
|
||||
PlusIcon,
|
||||
InternetIcon,
|
||||
} from "outline-icons";
|
||||
import React, { ComponentProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
|
||||
import { Integrations } from "~/scenes/Settings/Integrations";
|
||||
import ZapierIcon from "~/components/Icons/ZapierIcon";
|
||||
import { createLazyComponent as lazy } from "~/components/LazyLoad";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import lazy from "~/utils/lazyWithRetry";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
import { useComputed } from "./useComputed";
|
||||
import useCurrentTeam from "./useCurrentTeam";
|
||||
import useCurrentUser from "./useCurrentUser";
|
||||
import usePolicy from "./usePolicy";
|
||||
import useStores from "./useStores";
|
||||
|
||||
const ApiKeys = lazy(() => import("~/scenes/Settings/ApiKeys"));
|
||||
const 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"));
|
||||
@@ -48,23 +53,32 @@ export type ConfigItem = {
|
||||
path: string;
|
||||
icon: React.FC<ComponentProps<typeof Icon>>;
|
||||
component: React.ComponentType;
|
||||
description?: string;
|
||||
preload?: () => void;
|
||||
enabled: boolean;
|
||||
group: string;
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
const useSettingsConfig = () => {
|
||||
const { integrations } = useStores();
|
||||
const user = useCurrentUser();
|
||||
const team = useCurrentTeam();
|
||||
const can = usePolicy(team);
|
||||
const { t } = useTranslation();
|
||||
|
||||
React.useEffect(() => {
|
||||
void integrations.fetchAll();
|
||||
}, [integrations]);
|
||||
|
||||
const config = useComputed(() => {
|
||||
const items: ConfigItem[] = [
|
||||
// Account
|
||||
{
|
||||
name: t("Profile"),
|
||||
path: settingsPath(),
|
||||
component: Profile,
|
||||
component: Profile.Component,
|
||||
preload: Profile.preload,
|
||||
enabled: true,
|
||||
group: t("Account"),
|
||||
icon: ProfileIcon,
|
||||
@@ -72,7 +86,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("Preferences"),
|
||||
path: settingsPath("preferences"),
|
||||
component: Preferences,
|
||||
component: Preferences.Component,
|
||||
preload: Preferences.preload,
|
||||
enabled: true,
|
||||
group: t("Account"),
|
||||
icon: SettingsIcon,
|
||||
@@ -80,24 +95,27 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("Notifications"),
|
||||
path: settingsPath("notifications"),
|
||||
component: Notifications,
|
||||
component: Notifications.Component,
|
||||
preload: Notifications.preload,
|
||||
enabled: true,
|
||||
group: t("Account"),
|
||||
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.Component,
|
||||
preload: APIAndApps.preload,
|
||||
enabled: true,
|
||||
group: t("Account"),
|
||||
icon: CodeIcon,
|
||||
icon: PadlockIcon,
|
||||
},
|
||||
// Workspace
|
||||
{
|
||||
name: t("Details"),
|
||||
path: settingsPath("details"),
|
||||
component: Details,
|
||||
component: Details.Component,
|
||||
preload: Details.preload,
|
||||
enabled: can.update,
|
||||
group: t("Workspace"),
|
||||
icon: TeamIcon,
|
||||
@@ -105,7 +123,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("Security"),
|
||||
path: settingsPath("security"),
|
||||
component: Security,
|
||||
component: Security.Component,
|
||||
preload: Security.preload,
|
||||
enabled: can.update,
|
||||
group: t("Workspace"),
|
||||
icon: PadlockIcon,
|
||||
@@ -113,7 +132,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("Features"),
|
||||
path: settingsPath("features"),
|
||||
component: Features,
|
||||
component: Features.Component,
|
||||
preload: Features.preload,
|
||||
enabled: can.update,
|
||||
group: t("Workspace"),
|
||||
icon: BeakerIcon,
|
||||
@@ -121,7 +141,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("Members"),
|
||||
path: settingsPath("members"),
|
||||
component: Members,
|
||||
component: Members.Component,
|
||||
preload: Members.preload,
|
||||
enabled: can.listUsers,
|
||||
group: t("Workspace"),
|
||||
icon: UserIcon,
|
||||
@@ -129,7 +150,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("Groups"),
|
||||
path: settingsPath("groups"),
|
||||
component: Groups,
|
||||
component: Groups.Component,
|
||||
preload: Groups.preload,
|
||||
enabled: can.listGroups,
|
||||
group: t("Workspace"),
|
||||
icon: GroupIcon,
|
||||
@@ -137,7 +159,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("Templates"),
|
||||
path: settingsPath("templates"),
|
||||
component: Templates,
|
||||
component: Templates.Component,
|
||||
preload: Templates.preload,
|
||||
enabled: can.readTemplate,
|
||||
group: t("Workspace"),
|
||||
icon: ShapesIcon,
|
||||
@@ -145,15 +168,26 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("API Keys"),
|
||||
path: settingsPath("api-keys"),
|
||||
component: ApiKeys,
|
||||
component: ApiKeys.Component,
|
||||
preload: ApiKeys.preload,
|
||||
enabled: can.listApiKeys,
|
||||
group: t("Workspace"),
|
||||
icon: CodeIcon,
|
||||
},
|
||||
{
|
||||
name: t("Applications"),
|
||||
path: settingsPath("applications"),
|
||||
component: Applications.Component,
|
||||
preload: Applications.preload,
|
||||
enabled: can.listOAuthClients,
|
||||
group: t("Workspace"),
|
||||
icon: InternetIcon,
|
||||
},
|
||||
{
|
||||
name: t("Shared Links"),
|
||||
path: settingsPath("shares"),
|
||||
component: Shares,
|
||||
component: Shares.Component,
|
||||
preload: Shares.preload,
|
||||
enabled: can.listShares,
|
||||
group: t("Workspace"),
|
||||
icon: GlobeIcon,
|
||||
@@ -161,7 +195,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("Import"),
|
||||
path: settingsPath("import"),
|
||||
component: Import,
|
||||
component: Import.Component,
|
||||
preload: Import.preload,
|
||||
enabled: can.createImport,
|
||||
group: t("Workspace"),
|
||||
icon: ImportIcon,
|
||||
@@ -169,7 +204,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("Export"),
|
||||
path: settingsPath("export"),
|
||||
component: Export,
|
||||
component: Export.Component,
|
||||
preload: Export.preload,
|
||||
enabled: can.createExport,
|
||||
group: t("Workspace"),
|
||||
icon: ExportIcon,
|
||||
@@ -178,11 +214,20 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: "Zapier",
|
||||
path: integrationSettingsPath("zapier"),
|
||||
component: Zapier,
|
||||
component: Zapier.Component,
|
||||
preload: Zapier.preload,
|
||||
enabled: can.update && isCloudHosted,
|
||||
group: t("Integrations"),
|
||||
icon: ZapierIcon,
|
||||
},
|
||||
{
|
||||
name: `${t("Install")}…`,
|
||||
path: settingsPath("integrations"),
|
||||
component: Integrations,
|
||||
enabled: true,
|
||||
group: t("Integrations"),
|
||||
icon: PlusIcon,
|
||||
},
|
||||
];
|
||||
|
||||
// Plugins
|
||||
@@ -198,11 +243,17 @@ const useSettingsConfig = () => {
|
||||
? integrationSettingsPath(plugin.id)
|
||||
: settingsPath(plugin.id),
|
||||
group: t(group),
|
||||
component: plugin.value.component,
|
||||
description: plugin.value.description,
|
||||
component: plugin.value.component.Component,
|
||||
preload: plugin.value.component.preload,
|
||||
enabled: plugin.value.enabled
|
||||
? plugin.value.enabled(team, user)
|
||||
: can.update,
|
||||
icon: plugin.value.icon,
|
||||
isActive: integrations.orderedData.some(
|
||||
(integration) =>
|
||||
integration.service.toLowerCase() === plugin.id.toLowerCase()
|
||||
),
|
||||
} as ConfigItem);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -331,6 +331,16 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the documents that link to this document.
|
||||
*
|
||||
* @returns documents that link to this document
|
||||
*/
|
||||
@computed
|
||||
get backlinks(): Document[] {
|
||||
return this.store.getBacklinkedDocuments(this.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns users that have been individually given access to the document.
|
||||
*
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ class Star extends Model {
|
||||
document?: Document;
|
||||
|
||||
/** The collection ID that is starred. */
|
||||
collectionId: string;
|
||||
collectionId?: string;
|
||||
|
||||
/** The collection that is starred. */
|
||||
@Relation(() => Collection, { onDelete: "cascade" })
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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}`}
|
||||
|
||||
+26
-4
@@ -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);
|
||||
@@ -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"));
|
||||
@@ -66,7 +66,6 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const { documents, collections, ui } = useStores();
|
||||
const [isFetching, setFetching] = React.useState(false);
|
||||
const [error, setError] = React.useState<Error | undefined>();
|
||||
const currentPath = location.pathname;
|
||||
const [, setLastVisitedPath] = useLastVisitedPath();
|
||||
@@ -120,21 +119,16 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
|
||||
React.useEffect(() => {
|
||||
async function fetchData() {
|
||||
if ((!can || !collection) && !error && !isFetching) {
|
||||
try {
|
||||
setError(undefined);
|
||||
setFetching(true);
|
||||
await collections.fetch(id);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
try {
|
||||
setError(undefined);
|
||||
await collections.fetch(id);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
}
|
||||
}
|
||||
|
||||
void fetchData();
|
||||
}, [collections, isFetching, collection, error, id, can]);
|
||||
}, []);
|
||||
|
||||
useCommandBarActions([editCollection], [ui.activeCollectionId ?? "none"]);
|
||||
|
||||
@@ -265,7 +259,7 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
path={collectionPath(collection.path, CollectionPath.Overview)}
|
||||
>
|
||||
{hasOverview ? (
|
||||
<CollectionDescription collection={collection} />
|
||||
<Overview collection={collection} />
|
||||
) : (
|
||||
<Redirect
|
||||
to={{
|
||||
|
||||
@@ -18,7 +18,7 @@ type Props = {
|
||||
};
|
||||
|
||||
function References({ document }: Props) {
|
||||
const { collections, documents } = useStores();
|
||||
const { documents } = useStores();
|
||||
const user = useCurrentUser();
|
||||
const location = useLocation();
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
@@ -27,10 +27,8 @@ function References({ document }: Props) {
|
||||
void documents.fetchBacklinks(document.id);
|
||||
}, [documents, document.id]);
|
||||
|
||||
const backlinks = documents.getBacklinkedDocuments(document.id);
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const backlinks = document.backlinks;
|
||||
const collection = document.collection;
|
||||
const children = collection
|
||||
? collection.getChildrenForDocument(document.id)
|
||||
: [];
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
`;
|
||||
@@ -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,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()}
|
||||
`;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
`;
|
||||
@@ -0,0 +1,3 @@
|
||||
import Login from "./Login";
|
||||
|
||||
export default Login;
|
||||
@@ -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);
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
@@ -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,62 @@
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Flex from "@shared/components/Flex";
|
||||
import Heading from "~/components/Heading";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import useSettingsConfig from "~/hooks/useSettingsConfig";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
import IntegrationCard from "./components/IntegrationCard";
|
||||
import { StickyFilters } from "./components/StickyFilters";
|
||||
|
||||
export function Integrations() {
|
||||
const { t } = useTranslation();
|
||||
let items = useSettingsConfig();
|
||||
const [query, setQuery] = React.useState("");
|
||||
|
||||
const handleQuery = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setQuery(event.target.value);
|
||||
};
|
||||
|
||||
items = items
|
||||
.filter(
|
||||
(item) =>
|
||||
item.group === "Integrations" &&
|
||||
item.enabled &&
|
||||
item.path !== settingsPath("integrations") &&
|
||||
item.name.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
.sort((item) => (item.isActive ? -1 : 1));
|
||||
|
||||
return (
|
||||
<Scene title={t("Integrations")}>
|
||||
<Heading>{t("Integrations")}</Heading>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans>
|
||||
Configure a variety of integrations with third-party services.
|
||||
</Trans>
|
||||
</Text>
|
||||
<StickyFilters gap={8}>
|
||||
<InputSearch
|
||||
short
|
||||
value={query}
|
||||
placeholder={`${t("Filter")}…`}
|
||||
onChange={handleQuery}
|
||||
/>
|
||||
</StickyFilters>
|
||||
|
||||
<CardsFlex gap={30} wrap>
|
||||
{items.map((item) => (
|
||||
<IntegrationCard key={item.path} integration={item} />
|
||||
))}
|
||||
</CardsFlex>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
const CardsFlex = styled(Flex)`
|
||||
margin-top: 20px;
|
||||
width: "100%";
|
||||
`;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,91 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import { ConfigItem } from "~/hooks/useSettingsConfig";
|
||||
import Button from "../../../components/Button";
|
||||
import Flex from "../../../components/Flex";
|
||||
import Text from "../../../components/Text";
|
||||
|
||||
type Props = {
|
||||
integration: ConfigItem;
|
||||
};
|
||||
|
||||
function IntegrationCard({ integration }: Props) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card as={Link} to={integration.path}>
|
||||
<Flex align="center" gap={8}>
|
||||
<integration.icon size={48} />
|
||||
<Flex auto column>
|
||||
<Name>{integration.name}</Name>
|
||||
{integration.isActive && <Status>{t("Connected")}</Status>}
|
||||
</Flex>
|
||||
<Button as={Link} to={integration.path} neutral>
|
||||
{integration.isActive ? t("Configure") : t("Connect")}
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<Description>{integration.description}</Description>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default IntegrationCard;
|
||||
|
||||
const Card = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
padding: 20px;
|
||||
width: 300px;
|
||||
background: ${s("background")};
|
||||
border: 1px solid ${s("inputBorder")};
|
||||
color: ${s("text")};
|
||||
border-radius: 8px;
|
||||
transition: box-shadow 200ms ease;
|
||||
cursor: var(--pointer);
|
||||
|
||||
&:hover {
|
||||
box-shadow: rgba(0, 0, 0, 0.08) 0px 2px 4px, rgba(0, 0, 0, 0.06) 0px 4px 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Name = styled(Text)`
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: ${s("text")};
|
||||
${ellipsis()}
|
||||
`;
|
||||
|
||||
const Description = styled(Text)`
|
||||
margin: 8px 0 0;
|
||||
font-size: 15px;
|
||||
max-width: 100%;
|
||||
color: ${s("textTertiary")};
|
||||
`;
|
||||
|
||||
const Status = styled(Text).attrs({
|
||||
type: "secondary",
|
||||
size: "small",
|
||||
as: "span",
|
||||
})`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
|
||||
background: radial-gradient(
|
||||
circle at center,
|
||||
${s("accent")} 0 33%,
|
||||
transparent 33%
|
||||
);
|
||||
border-radius: 50%;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { SettingsIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Breadcrumb from "~/components/Breadcrumb";
|
||||
import Scene from "~/components/Scene";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
|
||||
export function IntegrationScene({
|
||||
children,
|
||||
...rest
|
||||
}: React.ComponentProps<typeof Scene>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Scene
|
||||
left={
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{
|
||||
type: "route",
|
||||
title: t("Integrations"),
|
||||
icon: <SettingsIcon />,
|
||||
to: settingsPath("integrations"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
@@ -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")
|
||||
)}{" "}
|
||||
·{" "}
|
||||
</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 {
|
||||
|
||||
@@ -186,6 +186,13 @@ export default class CollectionsStore extends Store<Collection> {
|
||||
statusFilter: [CollectionStatusFilter.Archived],
|
||||
});
|
||||
|
||||
get(id: string): Collection | undefined {
|
||||
return (
|
||||
this.data.get(id) ??
|
||||
this.orderedData.find((collection) => id.endsWith(collection.urlId))
|
||||
);
|
||||
}
|
||||
|
||||
@computed
|
||||
get archived(): Collection[] {
|
||||
return orderBy(this.orderedData, "archivedAt", "desc").filter(
|
||||
|
||||
@@ -279,19 +279,14 @@ export default class DocumentsStore extends Store<Document> {
|
||||
|
||||
@action
|
||||
fetchBacklinks = async (documentId: string): Promise<void> => {
|
||||
const res = await client.post(`/documents.list`, {
|
||||
const documents = await this.fetchAll({
|
||||
backlinkDocumentId: documentId,
|
||||
});
|
||||
invariant(res?.data, "Document list not available");
|
||||
const { data } = res;
|
||||
|
||||
runInAction("DocumentsStore#fetchBacklinks", () => {
|
||||
data.forEach(this.add);
|
||||
this.addPolicies(res.policies);
|
||||
|
||||
this.backlinks.set(
|
||||
documentId,
|
||||
data.map((doc: Partial<Document>) => doc.id)
|
||||
documents.map((doc) => doc.id)
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -300,8 +295,8 @@ export default class DocumentsStore extends Store<Document> {
|
||||
const documentIds = this.backlinks.get(documentId) || [];
|
||||
return orderBy(
|
||||
compact(documentIds.map((id) => this.data.get(id))),
|
||||
"updatedAt",
|
||||
"desc"
|
||||
"title",
|
||||
"asc"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import sortBy from "lodash/sortBy";
|
||||
import { action, observable } from "mobx";
|
||||
import Team from "~/models/Team";
|
||||
import User from "~/models/User";
|
||||
import { LazyComponent } from "~/components/LazyLoad";
|
||||
import { useComputed } from "~/hooks/useComputed";
|
||||
import Logger from "./Logger";
|
||||
import isCloudHosted from "./isCloudHosted";
|
||||
@@ -27,8 +28,10 @@ type PluginValueMap = {
|
||||
after?: string;
|
||||
/** The displayed icon of the plugin. */
|
||||
icon: React.ElementType;
|
||||
/** The settings screen somponent, should be lazy loaded. */
|
||||
component: React.LazyExoticComponent<React.ComponentType>;
|
||||
/** The lazy loaded settings screen component. */
|
||||
component: LazyComponent<React.ComponentType>;
|
||||
/** The description that will show on the plugins card. */
|
||||
description?: string;
|
||||
/** Whether the plugin is enabled in the current context. */
|
||||
enabled?: (team: Team, user: User) => boolean;
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+21
-19
@@ -48,18 +48,18 @@
|
||||
"> 0.25%, not dead"
|
||||
],
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.787.0",
|
||||
"@aws-sdk/lib-storage": "3.787.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.787.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.787.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.787.0",
|
||||
"@babel/core": "^7.26.10",
|
||||
"@babel/plugin-proposal-decorators": "^7.25.9",
|
||||
"@babel/plugin-transform-class-properties": "^7.25.9",
|
||||
"@babel/plugin-transform-destructuring": "^7.25.9",
|
||||
"@babel/plugin-transform-regenerator": "^7.27.0",
|
||||
"@babel/preset-env": "^7.26.9",
|
||||
"@babel/preset-react": "^7.26.3",
|
||||
"@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",
|
||||
@@ -140,7 +141,7 @@
|
||||
"jsdom": "^22.1.0",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"jszip": "^3.10.1",
|
||||
"katex": "^0.16.21",
|
||||
"katex": "^0.16.22",
|
||||
"kbar": "0.1.0-beta.41",
|
||||
"koa": "^2.16.1",
|
||||
"koa-body": "^6.0.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,9 +249,9 @@
|
||||
"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.3",
|
||||
"vite": "^6.3.4",
|
||||
"vite-plugin-pwa": "^0.21.2",
|
||||
"winston": "^3.17.0",
|
||||
"ws": "^7.5.10",
|
||||
@@ -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",
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton";
|
||||
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import Heading from "~/components/Heading";
|
||||
@@ -11,7 +12,6 @@ import List from "~/components/List";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Notice from "~/components/Notice";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
import Scene from "~/components/Scene";
|
||||
import TeamLogo from "~/components/TeamLogo";
|
||||
import Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
@@ -38,7 +38,7 @@ function GitHub() {
|
||||
}, [integrations]);
|
||||
|
||||
return (
|
||||
<Scene title="GitHub" icon={<GitHubIcon />}>
|
||||
<IntegrationScene title="GitHub" icon={<GitHubIcon />}>
|
||||
<Heading>GitHub</Heading>
|
||||
|
||||
{error === "access_denied" && (
|
||||
@@ -146,7 +146,7 @@ function GitHub() {
|
||||
</Trans>
|
||||
</Notice>
|
||||
)}
|
||||
</Scene>
|
||||
</IntegrationScene>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from "react";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
@@ -10,7 +10,9 @@ PluginManager.add([
|
||||
value: {
|
||||
group: "Integrations",
|
||||
icon: Icon,
|
||||
component: React.lazy(() => import("./Settings")),
|
||||
description:
|
||||
"Connect your GitHub account to Outline to enable rich, realtime, issue and pull request previews inside documents.",
|
||||
component: createLazyComponent(() => import("./Settings")),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -6,12 +6,12 @@ import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { IntegrationType, IntegrationService } from "@shared/types";
|
||||
import Integration from "~/models/Integration";
|
||||
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
|
||||
import SettingRow from "~/scenes/Settings/components/SettingRow";
|
||||
import Button from "~/components/Button";
|
||||
import Heading from "~/components/Heading";
|
||||
import GoogleIcon from "~/components/Icons/GoogleIcon";
|
||||
import Input from "~/components/Input";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -75,7 +75,7 @@ function GoogleAnalytics() {
|
||||
);
|
||||
|
||||
return (
|
||||
<Scene title={t("Google Analytics")} icon={<GoogleIcon />}>
|
||||
<IntegrationScene title={t("Google Analytics")} icon={<GoogleIcon />}>
|
||||
<Heading>{t("Google Analytics")}</Heading>
|
||||
|
||||
<Text as="p" type="secondary">
|
||||
@@ -100,7 +100,7 @@ function GoogleAnalytics() {
|
||||
{formState.isSubmitting ? `${t("Saving")}…` : t("Save")}
|
||||
</Button>
|
||||
</form>
|
||||
</Scene>
|
||||
</IntegrationScene>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from "react";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
@@ -10,7 +10,9 @@ PluginManager.add([
|
||||
value: {
|
||||
group: "Integrations",
|
||||
icon: Icon,
|
||||
component: React.lazy(() => import("./Settings")),
|
||||
description:
|
||||
"Measure adoption and engagement by sending view and event analytics directly to your GA4 dashboard.",
|
||||
component: createLazyComponent(() => import("./Settings")),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "googleanalytics",
|
||||
"id": "google-analytics",
|
||||
"name": "Google Analytics",
|
||||
"priority": 30,
|
||||
"description": "Adds support for reporting analytics to a Google."
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from "react";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
@@ -10,7 +10,9 @@ PluginManager.add([
|
||||
value: {
|
||||
group: "Integrations",
|
||||
icon: Icon,
|
||||
component: React.lazy(() => import("./Settings")),
|
||||
description:
|
||||
"Connect your Linear account to Outline to enable rich, realtime, issue previews inside documents.",
|
||||
component: createLazyComponent(() => import("./Settings")),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -6,11 +6,11 @@ import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { IntegrationType, IntegrationService } from "@shared/types";
|
||||
import Integration from "~/models/Integration";
|
||||
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
|
||||
import SettingRow from "~/scenes/Settings/components/SettingRow";
|
||||
import Button from "~/components/Button";
|
||||
import Heading from "~/components/Heading";
|
||||
import Input from "~/components/Input";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Icon from "./Icon";
|
||||
@@ -82,7 +82,7 @@ function Matomo() {
|
||||
);
|
||||
|
||||
return (
|
||||
<Scene title="Matomo" icon={<Icon />}>
|
||||
<IntegrationScene title="Matomo" icon={<Icon />}>
|
||||
<Heading>Matomo</Heading>
|
||||
|
||||
<Text as="p" type="secondary">
|
||||
@@ -121,7 +121,7 @@ function Matomo() {
|
||||
{formState.isSubmitting ? `${t("Saving")}…` : t("Save")}
|
||||
</Button>
|
||||
</form>
|
||||
</Scene>
|
||||
</IntegrationScene>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { UserRole } from "@shared/types";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
@@ -11,7 +11,9 @@ PluginManager.add([
|
||||
value: {
|
||||
group: "Integrations",
|
||||
icon: Icon,
|
||||
component: React.lazy(() => import("./Settings")),
|
||||
description:
|
||||
"Track your docs with a self-hosted, open-source analytics platform, link Outline to Matomo for 100% data ownership, GDPR compliance, and deep usage insights on your own servers.",
|
||||
component: createLazyComponent(() => import("./Settings")),
|
||||
enabled: (_, user) => user.role === UserRole.Admin,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -66,6 +66,6 @@ export class NotionImportsProcessor extends ImportsProcessor<IntegrationService.
|
||||
protected async scheduleTask(
|
||||
importTask: ImportTask<IntegrationService.Notion>
|
||||
): Promise<void> {
|
||||
await NotionAPIImportTask.schedule({ importTaskId: importTask.id });
|
||||
await new NotionAPIImportTask().schedule({ importTaskId: importTask.id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
|
||||
protected async scheduleNextTask(
|
||||
importTask: ImportTask<IntegrationService.Notion>
|
||||
) {
|
||||
await NotionAPIImportTask.schedule({ importTaskId: importTask.id });
|
||||
await new NotionAPIImportTask().schedule({ importTaskId: importTask.id });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -288,7 +288,7 @@ export class NotionConverter {
|
||||
if (item.mention.type === "link_mention") {
|
||||
return {
|
||||
type: "text",
|
||||
text: item.plain_text,
|
||||
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) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import Integration from "~/models/Integration";
|
||||
import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton";
|
||||
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
|
||||
import SettingRow from "~/scenes/Settings/components/SettingRow";
|
||||
import Flex from "~/components/Flex";
|
||||
import Heading from "~/components/Heading";
|
||||
@@ -13,7 +14,6 @@ import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import List from "~/components/List";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Notice from "~/components/Notice";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
@@ -67,7 +67,7 @@ function Slack() {
|
||||
const appName = env.APP_NAME;
|
||||
|
||||
return (
|
||||
<Scene title="Slack" icon={<SlackIcon />}>
|
||||
<IntegrationScene title="Slack" icon={<SlackIcon />}>
|
||||
<Heading>Slack</Heading>
|
||||
|
||||
{error === "access_denied" && (
|
||||
@@ -205,7 +205,7 @@ function Slack() {
|
||||
</List>
|
||||
</>
|
||||
)}
|
||||
</Scene>
|
||||
</IntegrationScene>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { UserRole } from "@shared/types";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
@@ -11,7 +11,9 @@ PluginManager.add([
|
||||
value: {
|
||||
group: "Integrations",
|
||||
icon: Icon,
|
||||
component: React.lazy(() => import("./Settings")),
|
||||
description:
|
||||
"Search your knowledge base directly in Slack, get /outline search, rich link previews, and notifications on new or updated docs.",
|
||||
component: createLazyComponent(() => import("./Settings")),
|
||||
enabled: (_, user) =>
|
||||
[UserRole.Member, UserRole.Admin].includes(user.role),
|
||||
},
|
||||
|
||||
@@ -6,11 +6,11 @@ import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { IntegrationType, IntegrationService } from "@shared/types";
|
||||
import Integration from "~/models/Integration";
|
||||
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
|
||||
import SettingRow from "~/scenes/Settings/components/SettingRow";
|
||||
import Button from "~/components/Button";
|
||||
import Heading from "~/components/Heading";
|
||||
import Input from "~/components/Input";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Icon from "./Icon";
|
||||
@@ -85,7 +85,7 @@ function Umami() {
|
||||
);
|
||||
|
||||
return (
|
||||
<Scene title="Umami" icon={<Icon />}>
|
||||
<IntegrationScene title="Umami" icon={<Icon />}>
|
||||
<Heading>Umami</Heading>
|
||||
|
||||
<Text as="p" type="secondary">
|
||||
@@ -145,7 +145,7 @@ function Umami() {
|
||||
{formState.isSubmitting ? `${t("Saving")}…` : t("Save")}
|
||||
</Button>
|
||||
</form>
|
||||
</Scene>
|
||||
</IntegrationScene>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { UserRole } from "@shared/types";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
@@ -11,7 +11,9 @@ PluginManager.add([
|
||||
value: {
|
||||
group: "Integrations",
|
||||
icon: Icon,
|
||||
component: React.lazy(() => import("./Settings")),
|
||||
description:
|
||||
"Gain privacy-first insights into how your team consumes docs, inject your self-hosted Umami script across Outline pages to track views and engagement while retaining full control of your data.",
|
||||
component: createLazyComponent(() => import("./Settings")),
|
||||
enabled: (_, user) => user.role === UserRole.Admin,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from "react";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
@@ -8,9 +8,12 @@ PluginManager.add([
|
||||
...config,
|
||||
type: Hook.Settings,
|
||||
value: {
|
||||
group: "Integrations",
|
||||
group: "Workspace",
|
||||
after: "Shared Links",
|
||||
icon: Icon,
|
||||
component: React.lazy(() => import("./Settings")),
|
||||
description:
|
||||
"Automate downstream workflows with real-time JSON POSTs, subscribe to events in Outline so external systems can react instantly.",
|
||||
component: createLazyComponent(() => import("./Settings")),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -29,8 +29,12 @@ describe("WebhookProcessor", () => {
|
||||
|
||||
await processor.perform(event);
|
||||
|
||||
expect(DeliverWebhookTask.schedule).toHaveBeenCalled();
|
||||
expect(DeliverWebhookTask.schedule).toHaveBeenCalledWith({
|
||||
expect(
|
||||
jest.mocked(DeliverWebhookTask.prototype.schedule)
|
||||
).toHaveBeenCalled();
|
||||
expect(
|
||||
jest.mocked(DeliverWebhookTask.prototype.schedule)
|
||||
).toHaveBeenCalledWith({
|
||||
event,
|
||||
subscriptionId: subscription.id,
|
||||
});
|
||||
@@ -53,7 +57,9 @@ describe("WebhookProcessor", () => {
|
||||
|
||||
await processor.perform(event);
|
||||
|
||||
expect(DeliverWebhookTask.schedule).toHaveBeenCalledTimes(0);
|
||||
expect(
|
||||
jest.mocked(DeliverWebhookTask.prototype.schedule)
|
||||
).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("it schedules a delivery for the event for each subscription", async () => {
|
||||
@@ -79,13 +85,21 @@ describe("WebhookProcessor", () => {
|
||||
|
||||
await processor.perform(event);
|
||||
|
||||
expect(DeliverWebhookTask.schedule).toHaveBeenCalled();
|
||||
expect(DeliverWebhookTask.schedule).toHaveBeenCalledTimes(2);
|
||||
expect(DeliverWebhookTask.schedule).toHaveBeenCalledWith({
|
||||
expect(
|
||||
jest.mocked(DeliverWebhookTask.prototype.schedule)
|
||||
).toHaveBeenCalled();
|
||||
expect(
|
||||
jest.mocked(DeliverWebhookTask.prototype.schedule)
|
||||
).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
jest.mocked(DeliverWebhookTask.prototype.schedule)
|
||||
).toHaveBeenCalledWith({
|
||||
event,
|
||||
subscriptionId: subscription.id,
|
||||
});
|
||||
expect(DeliverWebhookTask.schedule).toHaveBeenCalledWith({
|
||||
expect(
|
||||
jest.mocked(DeliverWebhookTask.prototype.schedule)
|
||||
).toHaveBeenCalledWith({
|
||||
event,
|
||||
subscriptionId: subscriptionTwo.id,
|
||||
});
|
||||
|
||||
@@ -24,7 +24,10 @@ export default class WebhookProcessor extends BaseProcessor {
|
||||
|
||||
await Promise.all(
|
||||
applicableSubscriptions.map((subscription) =>
|
||||
DeliverWebhookTask.schedule({ event, subscriptionId: subscription.id })
|
||||
new DeliverWebhookTask().schedule({
|
||||
event,
|
||||
subscriptionId: subscription.id,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import invariant from "invariant";
|
||||
import { Transaction } from "sequelize";
|
||||
import { createContext } from "@server/context";
|
||||
import { traceFunction } from "@server/logging/tracing";
|
||||
@@ -24,7 +23,7 @@ type Props = {
|
||||
/** Position of moved document within document structure */
|
||||
index?: number;
|
||||
/** The IP address of the user moving the document */
|
||||
ip: string;
|
||||
ip: string | null;
|
||||
/** The database transaction to run within */
|
||||
transaction?: Transaction;
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -4,9 +4,11 @@ import DeleteAttachmentTask from "@server/queues/tasks/DeleteAttachmentTask";
|
||||
import { buildAttachment, buildDocument } from "@server/test/factories";
|
||||
import documentPermanentDeleter from "./documentPermanentDeleter";
|
||||
|
||||
jest.mock("@server/queues/tasks/DeleteAttachmentTask", () => ({
|
||||
schedule: jest.fn(),
|
||||
}));
|
||||
jest.mock("@server/queues/tasks/DeleteAttachmentTask");
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("documentPermanentDeleter", () => {
|
||||
it("should destroy documents", async () => {
|
||||
@@ -60,7 +62,9 @@ describe("documentPermanentDeleter", () => {
|
||||
await document.save();
|
||||
const countDeletedDoc = await documentPermanentDeleter([document]);
|
||||
expect(countDeletedDoc).toEqual(1);
|
||||
expect(DeleteAttachmentTask.schedule).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
jest.mocked(DeleteAttachmentTask.prototype.schedule)
|
||||
).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
await Document.unscoped().count({
|
||||
where: {
|
||||
|
||||
@@ -67,7 +67,7 @@ export default async function documentPermanentDeleter(documents: Document[]) {
|
||||
"commands",
|
||||
`Attachment ${attachmentId} scheduled for deletion`
|
||||
);
|
||||
await DeleteAttachmentTask.schedule({
|
||||
await new DeleteAttachmentTask().schedule({
|
||||
attachmentId,
|
||||
teamId: document.teamId,
|
||||
});
|
||||
|
||||
@@ -56,5 +56,5 @@ export default async function userSuspender({
|
||||
}
|
||||
);
|
||||
|
||||
await CleanupDemotedUserTask.schedule({ userId: user.id });
|
||||
await new CleanupDemotedUserTask().schedule({ userId: user.id });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user