diff --git a/app/actions/definitions/navigation.tsx b/app/actions/definitions/navigation.tsx
index a5c188c27..8a26452ff 100644
--- a/app/actions/definitions/navigation.tsx
+++ b/app/actions/definitions/navigation.tsx
@@ -28,6 +28,7 @@ import history from "~/utils/history";
import {
organizationSettingsPath,
profileSettingsPath,
+ accountPreferencesPath,
homePath,
searchPath,
draftsPath,
@@ -104,6 +105,14 @@ export const navigateToProfileSettings = createAction({
perform: () => history.push(profileSettingsPath()),
});
+export const navigateToAccountPreferences = createAction({
+ name: ({ t }) => t("Preferences"),
+ section: NavigationSection,
+ iconInContextMenu: false,
+ icon: ,
+ perform: () => history.push(accountPreferencesPath()),
+});
+
export const openAPIDocumentation = createAction({
name: ({ t }) => t("API documentation"),
section: NavigationSection,
diff --git a/app/hooks/useAuthorizedSettingsConfig.ts b/app/hooks/useAuthorizedSettingsConfig.ts
index aca3a250a..0783bbf14 100644
--- a/app/hooks/useAuthorizedSettingsConfig.ts
+++ b/app/hooks/useAuthorizedSettingsConfig.ts
@@ -12,6 +12,7 @@ import {
BuildingBlocksIcon,
DownloadIcon,
WebhooksIcon,
+ SettingsIcon,
} from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
@@ -23,6 +24,7 @@ import Groups from "~/scenes/Settings/Groups";
import Import from "~/scenes/Settings/Import";
import Members from "~/scenes/Settings/Members";
import Notifications from "~/scenes/Settings/Notifications";
+import Preferences from "~/scenes/Settings/Preferences";
import Profile from "~/scenes/Settings/Profile";
import Security from "~/scenes/Settings/Security";
import Shares from "~/scenes/Settings/Shares";
@@ -34,6 +36,7 @@ import SlackIcon from "~/components/SlackIcon";
import ZapierIcon from "~/components/ZapierIcon";
import env from "~/env";
import isCloudHosted from "~/utils/isCloudHosted";
+import { accountPreferencesPath } from "~/utils/routeHelpers";
import useCurrentTeam from "./useCurrentTeam";
import usePolicy from "./usePolicy";
@@ -82,6 +85,14 @@ const useAuthorizedSettingsConfig = () => {
group: t("Account"),
icon: ProfileIcon,
},
+ Preferences: {
+ name: t("Preferences"),
+ path: accountPreferencesPath(),
+ component: Preferences,
+ enabled: true,
+ group: t("Account"),
+ icon: SettingsIcon,
+ },
Notifications: {
name: t("Notifications"),
path: "/settings/notifications",
diff --git a/app/hooks/useLastVisitedPath.tsx b/app/hooks/useLastVisitedPath.tsx
new file mode 100644
index 000000000..8564cea3e
--- /dev/null
+++ b/app/hooks/useLastVisitedPath.tsx
@@ -0,0 +1,14 @@
+import usePersistedState from "~/hooks/usePersistedState";
+
+export default function useLastVisitedPath() {
+ const [lastVisitedPath, setLastVisitedPath] = usePersistedState(
+ "lastVisitedPath",
+ "/"
+ );
+
+ const setPathAsLastVisitedPath = (path: string) => {
+ path !== lastVisitedPath && setLastVisitedPath(path);
+ };
+
+ return [lastVisitedPath, setPathAsLastVisitedPath];
+}
diff --git a/app/menus/AccountMenu.tsx b/app/menus/AccountMenu.tsx
index 1c63b5aa3..b275f168b 100644
--- a/app/menus/AccountMenu.tsx
+++ b/app/menus/AccountMenu.tsx
@@ -6,6 +6,7 @@ import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import {
navigateToProfileSettings,
+ navigateToAccountPreferences,
openKeyboardShortcuts,
openChangelog,
openAPIDocumentation,
@@ -44,6 +45,7 @@ const AccountMenu: React.FC = ({ children }) => {
openBugReportUrl,
changeTheme,
navigateToProfileSettings,
+ navigateToAccountPreferences,
separator(),
logout,
];
diff --git a/app/models/User.ts b/app/models/User.ts
index b36602a02..8a32069c6 100644
--- a/app/models/User.ts
+++ b/app/models/User.ts
@@ -1,7 +1,7 @@
import { subMinutes } from "date-fns";
import { computed, observable } from "mobx";
import { now } from "mobx-utils";
-import type { Role } from "@shared/types";
+import type { Role, UserPreferences } from "@shared/types";
import ParanoidModel from "./ParanoidModel";
import Field from "./decorators/Field";
@@ -26,6 +26,8 @@ class User extends ParanoidModel {
@observable
language: string;
+ preferences: UserPreferences | null | undefined;
+
email: string;
isAdmin: boolean;
diff --git a/app/scenes/Collection.tsx b/app/scenes/Collection.tsx
index 5cd993d64..05080b75e 100644
--- a/app/scenes/Collection.tsx
+++ b/app/scenes/Collection.tsx
@@ -8,6 +8,7 @@ import {
Route,
useHistory,
useRouteMatch,
+ useLocation,
} from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
@@ -30,6 +31,7 @@ import Tabs from "~/components/Tabs";
import Tooltip from "~/components/Tooltip";
import { editCollection } from "~/actions/definitions/collections";
import useCommandBarActions from "~/hooks/useCommandBarActions";
+import useLastVisitedPath from "~/hooks/useLastVisitedPath";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { collectionUrl, updateCollectionUrl } from "~/utils/routeHelpers";
@@ -42,16 +44,23 @@ function CollectionScene() {
const params = useParams<{ id?: string }>();
const history = useHistory();
const match = useRouteMatch();
+ const location = useLocation();
const { t } = useTranslation();
const { documents, pins, collections, ui } = useStores();
const [isFetching, setFetching] = React.useState(false);
const [error, setError] = React.useState();
+ const currentPath = location.pathname;
+ const [, setLastVisitedPath] = useLastVisitedPath();
const id = params.id || "";
const collection: Collection | null | undefined =
collections.getByUrl(id) || collections.get(id);
const can = usePolicy(collection?.id || "");
+ React.useEffect(() => {
+ setLastVisitedPath(currentPath);
+ }, [currentPath, setLastVisitedPath]);
+
React.useEffect(() => {
if (collection?.name) {
const canonicalUrl = updateCollectionUrl(match.url, collection);
diff --git a/app/scenes/Document/index.tsx b/app/scenes/Document/index.tsx
index 201dd25cb..f6e5583c1 100644
--- a/app/scenes/Document/index.tsx
+++ b/app/scenes/Document/index.tsx
@@ -2,6 +2,7 @@ import * as React from "react";
import { StaticContext } from "react-router";
import { RouteComponentProps } from "react-router-dom";
import useCurrentTeam from "~/hooks/useCurrentTeam";
+import useLastVisitedPath from "~/hooks/useLastVisitedPath";
import useStores from "~/hooks/useStores";
import DataLoader from "./components/DataLoader";
import Document from "./components/Document";
@@ -25,6 +26,12 @@ export default function DocumentScene(props: Props) {
const { ui } = useStores();
const team = useCurrentTeam();
const { documentSlug, revisionId } = props.match.params;
+ const currentPath = props.location.pathname;
+ const [, setLastVisitedPath] = useLastVisitedPath();
+
+ React.useEffect(() => {
+ setLastVisitedPath(currentPath);
+ }, [currentPath, setLastVisitedPath]);
React.useEffect(() => {
return () => ui.clearActiveDocument();
diff --git a/app/scenes/Login/index.tsx b/app/scenes/Login/index.tsx
index e08e120cd..66e961993 100644
--- a/app/scenes/Login/index.tsx
+++ b/app/scenes/Login/index.tsx
@@ -19,6 +19,7 @@ import PageTitle from "~/components/PageTitle";
import TeamLogo from "~/components/TeamLogo";
import Text from "~/components/Text";
import env from "~/env";
+import useLastVisitedPath from "~/hooks/useLastVisitedPath";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import isCloudHosted from "~/utils/isCloudHosted";
@@ -62,6 +63,9 @@ function Login({ children }: Props) {
const [error, setError] = React.useState(null);
const [emailLinkSentTo, setEmailLinkSentTo] = React.useState("");
const isCreate = location.pathname === "/create";
+ const rememberLastPath = !!auth.user?.preferences?.rememberLastPath;
+ const [lastVisitedPath] = useLastVisitedPath();
+
const handleReset = React.useCallback(() => {
setEmailLinkSentTo("");
}, []);
@@ -91,6 +95,14 @@ function Login({ children }: Props) {
}
}, [query]);
+ if (
+ auth.authenticated &&
+ rememberLastPath &&
+ lastVisitedPath !== location.pathname
+ ) {
+ return ;
+ }
+
if (auth.authenticated && auth.team?.defaultCollectionId) {
return ;
}
diff --git a/app/scenes/Settings/Preferences.tsx b/app/scenes/Settings/Preferences.tsx
new file mode 100644
index 000000000..0d3fe019c
--- /dev/null
+++ b/app/scenes/Settings/Preferences.tsx
@@ -0,0 +1,57 @@
+import { observer } from "mobx-react";
+import { SettingsIcon } from "outline-icons";
+import * as React from "react";
+import { useTranslation } from "react-i18next";
+import Heading from "~/components/Heading";
+import Scene from "~/components/Scene";
+import Switch from "~/components/Switch";
+import useCurrentUser from "~/hooks/useCurrentUser";
+import useStores from "~/hooks/useStores";
+import useToasts from "~/hooks/useToasts";
+import SettingRow from "./components/SettingRow";
+
+function Preferences() {
+ const { t } = useTranslation();
+ const { showToast } = useToasts();
+ const { auth } = useStores();
+ const user = useCurrentUser();
+
+ const handleChange = async (ev: React.ChangeEvent) => {
+ const newPreferences = {
+ ...user.preferences,
+ [ev.target.name]: ev.target.checked,
+ };
+
+ await auth.updateUser({
+ preferences: newPreferences,
+ });
+ showToast(t("Preferences saved"), {
+ type: "success",
+ });
+ };
+
+ return (
+ }
+ >
+ {t("Preferences")}
+
+
+
+
+ );
+}
+
+export default observer(Preferences);
diff --git a/app/stores/AuthStore.ts b/app/stores/AuthStore.ts
index f5b1f993d..9f8a05955 100644
--- a/app/stores/AuthStore.ts
+++ b/app/stores/AuthStore.ts
@@ -221,6 +221,7 @@ export default class AuthStore {
name?: string;
avatarUrl?: string | null;
language?: string;
+ preferences?: Record;
}) => {
this.isSaving = true;
diff --git a/app/utils/routeHelpers.test.ts b/app/utils/routeHelpers.test.ts
index 49dd176bb..f9a718755 100644
--- a/app/utils/routeHelpers.test.ts
+++ b/app/utils/routeHelpers.test.ts
@@ -1,4 +1,4 @@
-import { sharedDocumentPath } from "./routeHelpers";
+import { sharedDocumentPath, accountPreferencesPath } from "./routeHelpers";
describe("#sharedDocumentPath", () => {
test("should return share path for a document", () => {
@@ -12,3 +12,9 @@ describe("#sharedDocumentPath", () => {
);
});
});
+
+describe("#accountPreferencesPath", () => {
+ test("should return account preferences path", () => {
+ expect(accountPreferencesPath()).toBe("/settings/preferences");
+ });
+});
diff --git a/app/utils/routeHelpers.ts b/app/utils/routeHelpers.ts
index 7a7486655..fa7722054 100644
--- a/app/utils/routeHelpers.ts
+++ b/app/utils/routeHelpers.ts
@@ -34,6 +34,10 @@ export function profileSettingsPath(): string {
return "/settings";
}
+export function accountPreferencesPath(): string {
+ return "/settings/preferences";
+}
+
export function groupSettingsPath(): string {
return "/settings/groups";
}
diff --git a/server/models/User.ts b/server/models/User.ts
index 42568300b..e7ac269c7 100644
--- a/server/models/User.ts
+++ b/server/models/User.ts
@@ -22,7 +22,11 @@ import {
AllowNull,
} from "sequelize-typescript";
import { languages } from "@shared/i18n";
-import { CollectionPermission } from "@shared/types";
+import {
+ CollectionPermission,
+ UserPreference,
+ UserPreferences,
+} from "@shared/types";
import { stringToColor } from "@shared/utils/color";
import env from "@server/env";
import { ValidationError } from "../errors";
@@ -54,12 +58,6 @@ export enum UserRole {
Viewer = "viewer",
}
-export enum UserPreference {
- RememberLastPath = "rememberLastPath",
-}
-
-export type UserPreferences = { [key in UserPreference]?: boolean };
-
@Scopes(() => ({
withAuthentications: {
include: [
diff --git a/server/presenters/user.ts b/server/presenters/user.ts
index 03b280f62..abf5a90cc 100644
--- a/server/presenters/user.ts
+++ b/server/presenters/user.ts
@@ -1,6 +1,6 @@
+import { UserPreferences } from "@shared/types";
import env from "@server/env";
import { User } from "@server/models";
-import { UserPreferences } from "@server/models/User";
type Options = {
includeDetails?: boolean;
diff --git a/server/routes/api/users.ts b/server/routes/api/users.ts
index 5cba9404e..cc3974439 100644
--- a/server/routes/api/users.ts
+++ b/server/routes/api/users.ts
@@ -1,6 +1,8 @@
import crypto from "crypto";
import Router from "koa-router";
+import { has } from "lodash";
import { Op, WhereOptions } from "sequelize";
+import { UserPreference } from "@shared/types";
import { UserValidation } from "@shared/validations";
import { RateLimiterStrategy } from "@server/RateLimiter";
import userDemoter from "@server/commands/userDemoter";
@@ -17,7 +19,7 @@ import logger from "@server/logging/Logger";
import auth from "@server/middlewares/authentication";
import { rateLimiter } from "@server/middlewares/rateLimiter";
import { Event, User, Team } from "@server/models";
-import { UserFlag, UserRole, UserPreference } from "@server/models/User";
+import { UserFlag, UserRole } from "@server/models/User";
import { can, authorize } from "@server/policies";
import { presentUser, presentPolicies } from "@server/presenters";
import {
@@ -188,7 +190,7 @@ router.post("users.update", auth(), async (ctx) => {
}
if (preferences) {
assertKeysIn(preferences, UserPreference);
- if (preferences.rememberLastPath) {
+ if (has(preferences, UserPreference.RememberLastPath)) {
assertBoolean(preferences.rememberLastPath);
user.setPreference(
UserPreference.RememberLastPath,
diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json
index b397f789c..6d5122d07 100644
--- a/shared/i18n/locales/en_US/translation.json
+++ b/shared/i18n/locales/en_US/translation.json
@@ -50,6 +50,7 @@
"Trash": "Trash",
"Settings": "Settings",
"Profile": "Profile",
+ "Preferences": "Preferences",
"API documentation": "API documentation",
"Send us feedback": "Send us feedback",
"Report a bug": "Report a bug",
@@ -683,6 +684,9 @@
"Email address": "Email address",
"Your email address should be updated in your SSO provider.": "Your email address should be updated in your SSO provider.",
"The email integration is currently disabled. Please set the associated environment variables and restart the server to enable notifications.": "The email integration is currently disabled. Please set the associated environment variables and restart the server to enable notifications.",
+ "Preferences saved": "Preferences saved",
+ "Remember previous location": "Remember previous location",
+ "Automatically return to the document you were last viewing when the app is re-opened": "Automatically return to the document you were last viewing when the app is re-opened",
"Profile saved": "Profile saved",
"Profile picture updated": "Profile picture updated",
"Unable to upload new profile picture": "Unable to upload new profile picture",
diff --git a/shared/types.ts b/shared/types.ts
index 131fe0b6b..ac8c70022 100644
--- a/shared/types.ts
+++ b/shared/types.ts
@@ -43,3 +43,9 @@ export type IntegrationSettings = T extends IntegrationType.Embed
| { url: string }
| { url: string; channel: string; channelId: string }
| { serviceTeamId: string };
+
+export enum UserPreference {
+ RememberLastPath = "rememberLastPath",
+}
+
+export type UserPreferences = { [key in UserPreference]?: boolean };