diff --git a/app/components/DefaultCollectionInputSelect.tsx b/app/components/DefaultCollectionInputSelect.tsx
new file mode 100644
index 000000000..99f8222f2
--- /dev/null
+++ b/app/components/DefaultCollectionInputSelect.tsx
@@ -0,0 +1,103 @@
+import { HomeIcon } from "outline-icons";
+import React, { useState } from "react";
+import { useTranslation } from "react-i18next";
+import CollectionIcon from "~/components/CollectionIcon";
+import Flex from "~/components/Flex";
+import InputSelect from "~/components/InputSelect";
+import { IconWrapper } from "~/components/Sidebar/components/SidebarLink";
+import useStores from "~/hooks/useStores";
+import useToasts from "~/hooks/useToasts";
+
+type DefaultCollectionInputSelectProps = {
+ onSelectCollection: (collection: string) => void;
+ defaultCollectionId: string | null;
+};
+
+const DefaultCollectionInputSelect = ({
+ onSelectCollection,
+ defaultCollectionId,
+}: DefaultCollectionInputSelectProps) => {
+ const { t } = useTranslation();
+ const { collections } = useStores();
+ const [fetching, setFetching] = useState(false);
+ const [fetchError, setFetchError] = useState();
+ const { showToast } = useToasts();
+
+ React.useEffect(() => {
+ async function load() {
+ if (!collections.isLoaded && !fetching && !fetchError) {
+ try {
+ setFetching(true);
+ await collections.fetchPage({
+ limit: 100,
+ });
+ } catch (error) {
+ showToast(
+ t("Collections could not be loaded, please reload the app"),
+ {
+ type: "error",
+ }
+ );
+ setFetchError(error);
+ } finally {
+ setFetching(false);
+ }
+ }
+ }
+ load();
+ }, [showToast, fetchError, t, fetching, collections]);
+
+ const options = React.useMemo(
+ () =>
+ collections.publicCollections.reduce(
+ (acc, collection) => [
+ ...acc,
+ {
+ label: (
+
+
+
+
+ {collection.name}
+
+ ),
+ value: collection.id,
+ },
+ ],
+ [
+ {
+ label: (
+
+
+
+
+ {t("Home")}
+
+ ),
+ value: "home",
+ },
+ ]
+ ),
+ [collections.publicCollections, t]
+ );
+
+ if (fetching) {
+ return null;
+ }
+
+ return (
+
+ );
+};
+
+export default DefaultCollectionInputSelect;
diff --git a/app/components/InputSelect.tsx b/app/components/InputSelect.tsx
index a57bb3050..60a509ebb 100644
--- a/app/components/InputSelect.tsx
+++ b/app/components/InputSelect.tsx
@@ -18,12 +18,12 @@ import { MenuAnchorCSS } from "./ContextMenu/MenuItem";
import { LabelText } from "./Input";
export type Option = {
- label: string;
+ label: string | JSX.Element;
value: string;
};
export type Props = {
- value?: string;
+ value?: string | null;
label?: string;
nude?: boolean;
ariaLabel: string;
@@ -37,16 +37,13 @@ export type Props = {
onChange: (value: string | null) => void;
};
-const getOptionFromValue = (
- options: Option[],
- value: string | undefined | null
-) => {
+const getOptionFromValue = (options: Option[], value: string | null) => {
return options.find((option) => option.value === value);
};
const InputSelect = (props: Props) => {
const {
- value,
+ value = null,
label,
className,
labelHidden,
@@ -72,7 +69,7 @@ const InputSelect = (props: Props) => {
disabled,
});
- const previousValue = React.useRef(value);
+ const previousValue = React.useRef(value);
const contentRef = React.useRef(null);
const selectedRef = React.useRef(null);
const buttonRef = React.useRef(null);
@@ -82,6 +79,10 @@ const InputSelect = (props: Props) => {
select.visible,
select.unstable_disclosureRef
);
+ const wrappedLabel = {label};
+ const selectedValueIndex = options.findIndex(
+ (option) => option.value === select.selectedValue
+ );
React.useEffect(() => {
if (previousValue.current === select.selectedValue) {
@@ -95,10 +96,6 @@ const InputSelect = (props: Props) => {
load();
}, [onChange, select.selectedValue]);
- const wrappedLabel = {label};
- const selectedValueIndex = options.findIndex(
- (option) => option.value === select.selectedValue
- );
// Ensure selected option is visible when opening the input
React.useEffect(() => {
@@ -182,30 +179,23 @@ const InputSelect = (props: Props) => {
}
>
{select.visible
- ? options.map((option) => (
-
- {select.selectedValue !== undefined && (
- <>
- {select.selectedValue === option.value ? (
-
- ) : (
-
- )}
-
- >
- )}
- {option.label}
-
- ))
+ ? options.map((option) => {
+ const isSelected =
+ select.selectedValue === option.value;
+ const Icon = isSelected ? CheckmarkIcon : Spacer;
+ return (
+
+
+
+ {option.label}
+
+ );
+ })
: null}
@@ -261,6 +251,10 @@ const StyledButton = styled(Button)<{ nude?: boolean }>`
export const StyledSelectOption = styled(SelectOption)`
${MenuAnchorCSS}
+ /* overriding the styles from MenuAnchorCSS because we use here */
+ svg:not(:last-child) {
+ margin-right: 0px;
+ }
`;
const Wrapper = styled.label<{ short?: boolean }>`
diff --git a/app/components/Sidebar/components/SidebarLink.tsx b/app/components/Sidebar/components/SidebarLink.tsx
index 9dbaf0d60..2d36a5be0 100644
--- a/app/components/Sidebar/components/SidebarLink.tsx
+++ b/app/components/Sidebar/components/SidebarLink.tsx
@@ -99,7 +99,7 @@ function SidebarLink(
}
// accounts for whitespace around icon
-const IconWrapper = styled.span`
+export const IconWrapper = styled.span`
margin-left: -4px;
margin-right: 4px;
height: 24px;
diff --git a/app/components/SocketProvider.tsx b/app/components/SocketProvider.tsx
index 3f6536049..c083addab 100644
--- a/app/components/SocketProvider.tsx
+++ b/app/components/SocketProvider.tsx
@@ -188,11 +188,9 @@ class SocketProvider extends React.Component {
if (event.collectionIds) {
for (const collectionDescriptor of event.collectionIds) {
const collectionId = collectionDescriptor.id;
- const collection = collections.get(collectionId) || {};
+ const collection = collections.get(collectionId);
if (event.event === "collections.delete") {
- const collection = collections.get(collectionId);
-
if (collection) {
collection.deletedAt = collectionDescriptor.updatedAt;
}
@@ -211,10 +209,11 @@ class SocketProvider extends React.Component {
// if we already have the latest version (it was us that performed
// the change) then we don't need to update anything either.
- // @ts-expect-error ts-migrate(2339) FIXME: Property 'updatedAt' does not exist on type '{}'.
- const { updatedAt } = collection;
- if (updatedAt === collectionDescriptor.updatedAt) {
+ if (
+ collection &&
+ collection.updatedAt === collectionDescriptor.updatedAt
+ ) {
continue;
}
diff --git a/app/models/Team.ts b/app/models/Team.ts
index d8fe7167e..8790be6a1 100644
--- a/app/models/Team.ts
+++ b/app/models/Team.ts
@@ -27,6 +27,10 @@ class Team extends BaseModel {
@observable
documentEmbeds: boolean;
+ @Field
+ @observable
+ defaultCollectionId: string | null;
+
@Field
@observable
guestSignin: boolean;
diff --git a/app/routes/authenticated.tsx b/app/routes/authenticated.tsx
index 309f2c10d..79cbe3bc8 100644
--- a/app/routes/authenticated.tsx
+++ b/app/routes/authenticated.tsx
@@ -87,7 +87,7 @@ export default function AuthenticatedRoutes() {
- {" "}
+
diff --git a/app/scenes/CollectionDelete.tsx b/app/scenes/CollectionDelete.tsx
index 18b744740..6afdfc14c 100644
--- a/app/scenes/CollectionDelete.tsx
+++ b/app/scenes/CollectionDelete.tsx
@@ -6,6 +6,7 @@ import Collection from "~/models/Collection";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import HelpText from "~/components/HelpText";
+import useCurrentTeam from "~/hooks/useCurrentTeam";
import useToasts from "~/hooks/useToasts";
import { homePath } from "~/utils/routeHelpers";
@@ -16,6 +17,7 @@ type Props = {
function CollectionDelete({ collection, onSubmit }: Props) {
const [isDeleting, setIsDeleting] = React.useState(false);
+ const team = useCurrentTeam();
const { showToast } = useToasts();
const history = useHistory();
const { t } = useTranslation();
@@ -26,8 +28,8 @@ function CollectionDelete({ collection, onSubmit }: Props) {
try {
await collection.delete();
- history.push(homePath());
onSubmit();
+ history.push(homePath());
} catch (err) {
showToast(err.message, {
type: "error",
@@ -36,7 +38,7 @@ function CollectionDelete({ collection, onSubmit }: Props) {
setIsDeleting(false);
}
},
- [showToast, onSubmit, collection, history]
+ [collection, history, onSubmit, showToast]
);
return (
@@ -53,6 +55,19 @@ function CollectionDelete({ collection, onSubmit }: Props) {
}}
/>
+ {team.defaultCollectionId === collection.id ? (
+
+ ,
+ }}
+ />
+
+ ) : null}
diff --git a/app/scenes/Login/index.tsx b/app/scenes/Login/index.tsx
index 6ee5ff66c..3a8dbef24 100644
--- a/app/scenes/Login/index.tsx
+++ b/app/scenes/Login/index.tsx
@@ -6,6 +6,7 @@ import { Trans, useTranslation } from "react-i18next";
import { useLocation, Link, Redirect } from "react-router-dom";
import styled from "styled-components";
import { setCookie } from "tiny-cookie";
+import { Config } from "~/stores/AuthStore";
import ButtonLarge from "~/components/ButtonLarge";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
@@ -22,8 +23,7 @@ import { changeLanguage, detectLanguage } from "~/utils/language";
import Notices from "./Notices";
import Provider from "./Provider";
-// @ts-expect-error ts-migrate(7031) FIXME: Binding element 'config' implicitly has an 'any' t... Remove this comment to see the full error message
-function Header({ config }) {
+function Header({ config }: { config: Config }) {
const { t } = useTranslation();
const isHosted = env.DEPLOYMENT === "hosted";
const isSubdomain = !!config.hostname;
@@ -84,6 +84,10 @@ function Login() {
}
}, [query]);
+ if (auth.authenticated && auth.team?.defaultCollectionId) {
+ return ;
+ }
+
if (auth.authenticated) {
return ;
}
diff --git a/app/scenes/Settings/Details.tsx b/app/scenes/Settings/Details.tsx
index 0a3e83239..7067ae06d 100644
--- a/app/scenes/Settings/Details.tsx
+++ b/app/scenes/Settings/Details.tsx
@@ -4,6 +4,7 @@ import { useRef, useState } from "react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import Button from "~/components/Button";
+import DefaultCollectionInputSelect from "~/components/DefaultCollectionInputSelect";
import Heading from "~/components/Heading";
import HelpText from "~/components/HelpText";
import Input from "~/components/Input";
@@ -23,6 +24,9 @@ function Details() {
const [name, setName] = useState(team.name);
const [subdomain, setSubdomain] = useState(team.subdomain);
const [avatarUrl, setAvatarUrl] = useState(team.avatarUrl);
+ const [defaultCollectionId, setDefaultCollectionId] = useState(
+ team.defaultCollectionId
+ );
const handleSubmit = React.useCallback(
async (event?: React.SyntheticEvent) => {
@@ -35,6 +39,7 @@ function Details() {
name,
avatarUrl,
subdomain,
+ defaultCollectionId,
});
showToast(t("Settings saved"), {
type: "success",
@@ -45,7 +50,7 @@ function Details() {
});
}
},
- [auth, showToast, name, avatarUrl, subdomain, t]
+ [auth, name, avatarUrl, subdomain, defaultCollectionId, showToast, t]
);
const handleNameChange = React.useCallback(
@@ -79,7 +84,13 @@ function Details() {
[showToast, t]
);
+ const onSelectCollection = React.useCallback(async (value: string) => {
+ const defaultCollectionId = value === "home" ? null : value;
+ setDefaultCollectionId(defaultCollectionId);
+ }, []);
+
const isValid = form.current && form.current.checkValidity();
+
return (
}>
{t("Details")}
@@ -128,6 +139,10 @@ function Details() {
)}
>
)}
+
diff --git a/app/stores/AuthStore.ts b/app/stores/AuthStore.ts
index 226182eb3..36e902cc6 100644
--- a/app/stores/AuthStore.ts
+++ b/app/stores/AuthStore.ts
@@ -25,7 +25,7 @@ type Provider = {
authUrl: string;
};
-type Config = {
+export type Config = {
name?: string;
hostname?: string;
providers: Provider[];
@@ -228,6 +228,7 @@ export default class AuthStore {
avatarUrl?: string | null | undefined;
sharing?: boolean;
collaborativeEditing?: boolean;
+ defaultCollectionId?: string | null;
subdomain?: string | null | undefined;
}) => {
this.isSaving = true;
diff --git a/app/stores/CollectionsStore.ts b/app/stores/CollectionsStore.ts
index dcc24c1ac..8a5cea888 100644
--- a/app/stores/CollectionsStore.ts
+++ b/app/stores/CollectionsStore.ts
@@ -139,7 +139,10 @@ export default class CollectionsStore extends BaseStore {
}
@action
- async fetch(id: string, options: Record = {}): Promise {
+ async fetch(
+ id: string,
+ options: Record = {}
+ ): Promise {
const item = this.get(id) || this.getByUrl(id);
if (item && !options.force) {
return item;
@@ -164,6 +167,13 @@ export default class CollectionsStore extends BaseStore {
}
}
+ @computed
+ get publicCollections() {
+ return this.orderedData.filter((collection) =>
+ ["read", "read_write"].includes(collection.permission || "")
+ );
+ }
+
getPathForDocument(documentId: string): DocumentPath | undefined {
return this.pathsToDocuments.find((path) => path.id === documentId);
}
diff --git a/server/commands/teamUpdater.ts b/server/commands/teamUpdater.ts
new file mode 100644
index 000000000..7920f2cef
--- /dev/null
+++ b/server/commands/teamUpdater.ts
@@ -0,0 +1,88 @@
+import { Transaction } from "sequelize";
+import { sequelize } from "@server/database/sequelize";
+import { Event, Team, User } from "@server/models";
+
+type TeamUpdaterProps = {
+ params: Partial;
+ ip?: string;
+ user: User;
+ team: Team;
+};
+
+const teamUpdater = async ({ params, user, team, ip }: TeamUpdaterProps) => {
+ const {
+ name,
+ avatarUrl,
+ subdomain,
+ sharing,
+ guestSignin,
+ documentEmbeds,
+ collaborativeEditing,
+ defaultCollectionId,
+ defaultUserRole,
+ } = params;
+
+ if (subdomain !== undefined && process.env.SUBDOMAINS_ENABLED === "true") {
+ team.subdomain = subdomain === "" ? null : subdomain;
+ }
+
+ if (name) {
+ team.name = name;
+ }
+ if (sharing !== undefined) {
+ team.sharing = sharing;
+ }
+ if (documentEmbeds !== undefined) {
+ team.documentEmbeds = documentEmbeds;
+ }
+ if (guestSignin !== undefined) {
+ team.guestSignin = guestSignin;
+ }
+ if (avatarUrl !== undefined) {
+ team.avatarUrl = avatarUrl;
+ }
+ if (defaultCollectionId !== undefined) {
+ team.defaultCollectionId = defaultCollectionId;
+ }
+ if (collaborativeEditing !== undefined) {
+ team.collaborativeEditing = collaborativeEditing;
+ }
+ if (defaultUserRole !== undefined) {
+ team.defaultUserRole = defaultUserRole;
+ }
+
+ const changes = team.changed();
+
+ const transaction: Transaction = await sequelize.transaction();
+
+ try {
+ const savedTeam = await team.save({
+ transaction,
+ });
+ if (changes) {
+ const data = changes.reduce((acc, curr) => {
+ return { ...acc, [curr]: team[curr] };
+ }, {});
+
+ await Event.create(
+ {
+ name: "teams.update",
+ actorId: user.id,
+ teamId: user.teamId,
+ data,
+ ip: ip,
+ },
+ {
+ transaction,
+ }
+ );
+ }
+ await transaction.commit();
+ return savedTeam;
+ } catch (error) {
+ await transaction.rollback();
+ throw error;
+ }
+};
+
+export default teamUpdater;
diff --git a/server/migrations/20220129092607-add-defaultCollectionId.js b/server/migrations/20220129092607-add-defaultCollectionId.js
new file mode 100644
index 000000000..331e79179
--- /dev/null
+++ b/server/migrations/20220129092607-add-defaultCollectionId.js
@@ -0,0 +1,15 @@
+'use strict';
+
+module.exports = {
+ up: async (queryInterface, Sequelize) => {
+ await queryInterface.addColumn("teams", "defaultCollectionId", {
+ type: Sequelize.UUID,
+ defaultValue: null,
+ allowNull: true,
+ });
+ },
+
+ down: async (queryInterface, Sequelize) => {
+ await queryInterface.removeColumn("teams", "defaultCollectionId");
+ }
+};
diff --git a/server/models/Team.ts b/server/models/Team.ts
index ebb6c6885..d3e0d800d 100644
--- a/server/models/Team.ts
+++ b/server/models/Team.ts
@@ -66,6 +66,9 @@ class Team extends ParanoidModel {
@Column
domain: string | null;
+ @Column(DataType.UUID)
+ defaultCollectionId: string | null;
+
@Column
avatarUrl: string | null;
diff --git a/server/presenters/team.ts b/server/presenters/team.ts
index d5b7df64b..8ce13dc66 100644
--- a/server/presenters/team.ts
+++ b/server/presenters/team.ts
@@ -7,6 +7,7 @@ export default function present(team: Team) {
avatarUrl: team.logoUrl,
sharing: team.sharing,
collaborativeEditing: team.collaborativeEditing,
+ defaultCollectionId: team.defaultCollectionId,
documentEmbeds: team.documentEmbeds,
guestSignin: team.guestSignin,
subdomain: team.subdomain,
diff --git a/server/routes/api/collections.ts b/server/routes/api/collections.ts
index 66ef572c2..a76837df6 100644
--- a/server/routes/api/collections.ts
+++ b/server/routes/api/collections.ts
@@ -3,6 +3,7 @@ import invariant from "invariant";
import Router from "koa-router";
import { Sequelize, Op, WhereOptions } from "sequelize";
import collectionExporter from "@server/commands/collectionExporter";
+import teamUpdater from "@server/commands/teamUpdater";
import { ValidationError } from "@server/errors";
import auth from "@server/middlewares/authentication";
import {
@@ -601,6 +602,19 @@ router.post("collections.update", auth(), async (ctx) => {
// if the privacy level has changed. Otherwise skip this query for speed.
if (privacyChanged || sharingChanged) {
await collection.reload();
+ const team = await Team.findByPk(user.teamId);
+
+ if (
+ collection.permission === null &&
+ team?.defaultCollectionId === collection.id
+ ) {
+ await teamUpdater({
+ params: { defaultCollectionId: null },
+ ip: ctx.request.ip,
+ user,
+ team,
+ });
+ }
}
ctx.body = {
@@ -612,17 +626,19 @@ router.post("collections.update", auth(), async (ctx) => {
router.post("collections.list", auth(), pagination(), async (ctx) => {
const { user } = ctx.state;
const collectionIds = await user.collectionIds();
+ const where: WhereOptions = {
+ teamId: user.teamId,
+ id: collectionIds,
+ };
const collections = await Collection.scope({
method: ["withMembership", user.id],
}).findAll({
- where: {
- teamId: user.teamId,
- id: collectionIds,
- },
+ where,
order: [["updatedAt", "DESC"]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
+
const nullIndex = collections.findIndex(
(collection) => collection.index === null
);
@@ -649,6 +665,8 @@ router.post("collections.delete", auth(), async (ctx) => {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id);
+ const team = await Team.findByPk(user.teamId);
+
authorize(user, "delete", collection);
const total = await Collection.count();
@@ -657,6 +675,16 @@ router.post("collections.delete", auth(), async (ctx) => {
}
await collection.destroy();
+
+ if (team && team.defaultCollectionId === collection.id) {
+ await teamUpdater({
+ params: { defaultCollectionId: null },
+ ip: ctx.request.ip,
+ user,
+ team,
+ });
+ }
+
await Event.create({
name: "collections.delete",
collectionId: collection.id,
diff --git a/server/routes/api/team.test.ts b/server/routes/api/team.test.ts
index 8a3fa58e7..f89304ef1 100644
--- a/server/routes/api/team.test.ts
+++ b/server/routes/api/team.test.ts
@@ -1,5 +1,6 @@
import TestServer from "fetch-test-server";
import webService from "@server/services/web";
+import { buildAdmin, buildCollection, buildTeam } from "@server/test/factories";
import { flushdb, seed } from "@server/test/support";
const app = webService();
@@ -70,4 +71,109 @@ describe("#team.update", () => {
const res = await server.post("/api/team.update");
expect(res.status).toEqual(401);
});
+
+ it("should update default collection", async () => {
+ const team = await buildTeam();
+ const admin = await buildAdmin({ teamId: team.id });
+ const collection = await buildCollection({
+ teamId: team.id,
+ userId: admin.id,
+ });
+
+ const res = await server.post("/api/team.update", {
+ body: {
+ token: admin.getJwtToken(),
+ defaultCollectionId: collection.id,
+ },
+ });
+ const body = await res.json();
+ expect(res.status).toEqual(200);
+ expect(body.data.defaultCollectionId).toEqual(collection.id);
+ });
+
+ it("should default to home if default collection is deleted", async () => {
+ const team = await buildTeam();
+ const admin = await buildAdmin({ teamId: team.id });
+ const collection = await buildCollection({
+ teamId: team.id,
+ userId: admin.id,
+ });
+
+ await buildCollection({
+ teamId: team.id,
+ userId: admin.id,
+ });
+
+ const res = await server.post("/api/team.update", {
+ body: {
+ token: admin.getJwtToken(),
+ defaultCollectionId: collection.id,
+ },
+ });
+
+ const body = await res.json();
+ expect(res.status).toEqual(200);
+ expect(body.data.defaultCollectionId).toEqual(collection.id);
+
+ const deleteRes = await server.post("/api/collections.delete", {
+ body: {
+ token: admin.getJwtToken(),
+ id: collection.id,
+ },
+ });
+ expect(deleteRes.status).toEqual(200);
+
+ const res3 = await server.post("/api/auth.info", {
+ body: {
+ token: admin.getJwtToken(),
+ },
+ });
+ const body3 = await res3.json();
+ expect(res3.status).toEqual(200);
+ expect(body3.data.team.defaultCollectionId).toEqual(null);
+ });
+
+ it("should update default collection to null when collection is made private", async () => {
+ const team = await buildTeam();
+ const admin = await buildAdmin({ teamId: team.id });
+ const collection = await buildCollection({
+ teamId: team.id,
+ userId: admin.id,
+ });
+
+ await buildCollection({
+ teamId: team.id,
+ userId: admin.id,
+ });
+
+ const res = await server.post("/api/team.update", {
+ body: {
+ token: admin.getJwtToken(),
+ defaultCollectionId: collection.id,
+ },
+ });
+
+ const body = await res.json();
+ expect(res.status).toEqual(200);
+ expect(body.data.defaultCollectionId).toEqual(collection.id);
+
+ const updateRes = await server.post("/api/collections.update", {
+ body: {
+ token: admin.getJwtToken(),
+ id: collection.id,
+ permission: "",
+ },
+ });
+
+ expect(updateRes.status).toEqual(200);
+
+ const res3 = await server.post("/api/auth.info", {
+ body: {
+ token: admin.getJwtToken(),
+ },
+ });
+ const body3 = await res3.json();
+ expect(res3.status).toEqual(200);
+ expect(body3.data.team.defaultCollectionId).toEqual(null);
+ });
});
diff --git a/server/routes/api/team.ts b/server/routes/api/team.ts
index e931a3d36..83cbd79f7 100644
--- a/server/routes/api/team.ts
+++ b/server/routes/api/team.ts
@@ -1,8 +1,10 @@
import Router from "koa-router";
+import teamUpdater from "@server/commands/teamUpdater";
import auth from "@server/middlewares/authentication";
-import { Event, Team } from "@server/models";
+import { Team } from "@server/models";
import { authorize } from "@server/policies";
import { presentTeam, presentPolicies } from "@server/presenters";
+import { assertUuid } from "@server/validation";
const router = new Router();
@@ -15,61 +17,38 @@ router.post("team.update", auth(), async (ctx) => {
guestSignin,
documentEmbeds,
collaborativeEditing,
+ defaultCollectionId,
defaultUserRole,
} = ctx.body;
+
const { user } = ctx.state;
const team = await Team.findByPk(user.teamId);
authorize(user, "update", team);
- if (subdomain !== undefined && process.env.SUBDOMAINS_ENABLED === "true") {
- team.subdomain = subdomain === "" ? null : subdomain;
+ if (defaultCollectionId !== undefined && defaultCollectionId !== null) {
+ assertUuid(defaultCollectionId, "defaultCollectionId must be uuid");
}
- if (name) {
- team.name = name;
- }
- if (sharing !== undefined) {
- team.sharing = sharing;
- }
- if (documentEmbeds !== undefined) {
- team.documentEmbeds = documentEmbeds;
- }
- if (guestSignin !== undefined) {
- team.guestSignin = guestSignin;
- }
- if (avatarUrl !== undefined) {
- team.avatarUrl = avatarUrl;
- }
-
- if (collaborativeEditing !== undefined) {
- team.collaborativeEditing = collaborativeEditing;
- }
-
- if (defaultUserRole !== undefined) {
- team.defaultUserRole = defaultUserRole;
- }
-
- const changes = team.changed();
- const data = {};
- await team.save();
-
- if (changes) {
- for (const change of changes) {
- data[change] = team[change];
- }
-
- await Event.create({
- name: "teams.update",
- actorId: user.id,
- teamId: user.teamId,
- data,
- ip: ctx.request.ip,
- });
- }
+ const updatedTeam = await teamUpdater({
+ params: {
+ name,
+ avatarUrl,
+ subdomain,
+ sharing,
+ guestSignin,
+ documentEmbeds,
+ collaborativeEditing,
+ defaultCollectionId,
+ defaultUserRole,
+ },
+ user,
+ team,
+ ip: ctx.request.ip,
+ });
ctx.body = {
- data: presentTeam(team),
- policies: presentPolicies(user, [team]),
+ data: presentTeam(updatedTeam),
+ policies: presentPolicies(user, [updatedTeam]),
};
});
diff --git a/server/routes/auth/index.ts b/server/routes/auth/index.ts
index e13434250..b634d4456 100644
--- a/server/routes/auth/index.ts
+++ b/server/routes/auth/index.ts
@@ -42,12 +42,29 @@ router.get("/redirect", auth(), async (ctx) => {
},
}),
]);
+
+ const defaultCollectionId = team?.defaultCollectionId;
+
+ if (defaultCollectionId) {
+ const collection = await Collection.findOne({
+ where: {
+ id: defaultCollectionId,
+ teamId: team.id,
+ },
+ });
+
+ if (collection) {
+ ctx.redirect(`${team.url}${collection.url}`);
+ return;
+ }
+ }
+
const hasViewedDocuments = !!view;
ctx.redirect(
!hasViewedDocuments && collection
- ? `${team!.url}${collection.url}`
- : `${team!.url}/home`
+ ? `${team?.url}${collection.url}`
+ : `${team?.url}/home`
);
});
diff --git a/server/utils/authentication.ts b/server/utils/authentication.ts
index 031aec44e..1847e589e 100644
--- a/server/utils/authentication.ts
+++ b/server/utils/authentication.ts
@@ -101,6 +101,23 @@ export async function signIn(
httpOnly: false,
expires,
});
+
+ const defaultCollectionId = team.defaultCollectionId;
+
+ if (defaultCollectionId) {
+ const collection = await Collection.findOne({
+ where: {
+ id: defaultCollectionId,
+ teamId: team.id,
+ },
+ });
+
+ if (collection) {
+ ctx.redirect(`${team.url}${collection.url}`);
+ return;
+ }
+ }
+
const [collection, view] = await Promise.all([
Collection.findFirstCollectionForUser(user),
View.findOne({
diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json
index 798a67d2b..82fd23850 100644
--- a/shared/i18n/locales/en_US/translation.json
+++ b/shared/i18n/locales/en_US/translation.json
@@ -62,6 +62,10 @@
"Server connection lost": "Server connection lost",
"Edits you make will sync once you’re online": "Edits you make will sync once you’re online",
"Submenu": "Submenu",
+ "Collections could not be loaded, please reload the app": "Collections could not be loaded, please reload the app",
+ "Start view": "Start view",
+ "Default collection": "Default collection",
+ "This is the screen that team members will first see when they sign in.": "This is the screen that team members will first see when they sign in.",
"Deleted Collection": "Deleted Collection",
"Unpin": "Unpin",
"History": "History",
@@ -130,7 +134,6 @@
"Back": "Back",
"Document archived": "Document archived",
"Move document": "Move document",
- "Collections could not be loaded, please reload the app": "Collections could not be loaded, please reload the app",
"Collections": "Collections",
"Untitled": "Untitled",
"New nested document": "New nested document",
@@ -298,6 +301,7 @@
"Create a document": "Create a document",
"Manage permissions": "Manage permissions",
"Are you sure about that? Deleting the {{collectionName}} collection is permanent and cannot be restored, however documents within will be moved to the trash.": "Are you sure about that? Deleting the {{collectionName}} collection is permanent and cannot be restored, however documents within will be moved to the trash.",
+ "Also, {{collectionName}} is being used as the start view – deleting it will reset the start view to the Home page.": "Also, {{collectionName}} is being used as the start view – deleting it will reset the start view to the Home page.",
"Deleting": "Deleting",
"I’m sure – Delete": "I’m sure – Delete",
"The collection was updated": "The collection was updated",