From 42061edbd171305395f8e16885b87b45d85dc678 Mon Sep 17 00:00:00 2001 From: Saumya Pandey Date: Thu, 10 Feb 2022 10:06:10 +0530 Subject: [PATCH] feat: add the ability to choose default collection (#3029) Co-authored-by: Tom Moor Co-authored-by: Tom Moor --- .../DefaultCollectionInputSelect.tsx | 103 +++++++++++++++++ app/components/InputSelect.tsx | 66 +++++------ .../Sidebar/components/SidebarLink.tsx | 2 +- app/components/SocketProvider.tsx | 11 +- app/models/Team.ts | 4 + app/routes/authenticated.tsx | 2 +- app/scenes/CollectionDelete.tsx | 19 +++- app/scenes/Login/index.tsx | 8 +- app/scenes/Settings/Details.tsx | 17 ++- app/stores/AuthStore.ts | 3 +- app/stores/CollectionsStore.ts | 12 +- server/commands/teamUpdater.ts | 88 +++++++++++++++ .../20220129092607-add-defaultCollectionId.js | 15 +++ server/models/Team.ts | 3 + server/presenters/team.ts | 1 + server/routes/api/collections.ts | 36 +++++- server/routes/api/team.test.ts | 106 ++++++++++++++++++ server/routes/api/team.ts | 71 +++++------- server/routes/auth/index.ts | 21 +++- server/utils/authentication.ts | 17 +++ shared/i18n/locales/en_US/translation.json | 6 +- 21 files changed, 507 insertions(+), 104 deletions(-) create mode 100644 app/components/DefaultCollectionInputSelect.tsx create mode 100644 server/commands/teamUpdater.ts create mode 100644 server/migrations/20220129092607-add-defaultCollectionId.js 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",