feat: allow ad-hoc creation of new teams (#3964)

Co-authored-by: Tom Moor <tom@getoutline.com>
This commit is contained in:
Nan Yu
2022-10-16 08:57:27 -04:00
committed by GitHub
parent 1fbc000e03
commit 39fc8d5c14
33 changed files with 529 additions and 186 deletions

View File

@@ -1,40 +1,64 @@
import { PlusIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import TeamNew from "~/scenes/TeamNew";
import { createAction } from "~/actions";
import { loadSessionsFromCookie } from "~/hooks/useSessions";
import { TeamSection } from "../sections";
export const changeTeam = createAction({
name: ({ t }) => t("Switch team"),
placeholder: ({ t }) => t("Select a team"),
keywords: "change workspace organization",
section: "Account",
visible: ({ currentTeamId }) => {
const sessions = loadSessionsFromCookie();
const otherSessions = sessions.filter(
(session) => session.teamId !== currentTeamId
);
return otherSessions.length > 0;
export const switchTeamList = getSessions().map((session) => {
return createAction({
name: session.name,
section: TeamSection,
keywords: "change switch workspace organization team",
icon: () => <Logo alt={session.name} src={session.logoUrl} />,
visible: ({ currentTeamId }) => currentTeamId !== session.teamId,
perform: () => (window.location.href = session.url),
});
});
const switchTeam = createAction({
name: ({ t }) => t("Switch workspace"),
placeholder: ({ t }) => t("Select a workspace"),
keywords: "change switch workspace organization team",
section: TeamSection,
visible: ({ currentTeamId }) =>
getSessions({ exclude: currentTeamId }).length > 0,
children: switchTeamList,
});
export const createTeam = createAction({
name: ({ t }) => `${t("New workspace")}`,
keywords: "create change switch workspace organization team",
section: TeamSection,
icon: <PlusIcon />,
visible: ({ stores, currentTeamId }) => {
return stores.policies.abilities(currentTeamId ?? "").createTeam;
},
children: ({ currentTeamId }) => {
const sessions = loadSessionsFromCookie();
const otherSessions = sessions.filter(
(session) => session.teamId !== currentTeamId
);
return otherSessions.map((session) => ({
id: session.url,
name: session.name,
section: "Account",
icon: <Logo alt={session.name} src={session.logoUrl} />,
perform: () => (window.location.href = session.url),
}));
perform: ({ t, event, stores }) => {
event?.preventDefault();
event?.stopPropagation();
const { user } = stores.auth;
user &&
stores.dialogs.openModal({
title: t("Create a workspace"),
content: <TeamNew user={user} />,
});
},
});
function getSessions(params?: { exclude?: string }) {
const sessions = loadSessionsFromCookie();
const otherSessions = sessions.filter(
(session) => session.teamId !== params?.exclude
);
return otherSessions;
}
const Logo = styled("img")`
border-radius: 2px;
width: 24px;
height: 24px;
`;
export const rootTeamActions = [changeTeam];
export const rootTeamActions = [switchTeam, createTeam];

View File

@@ -8,7 +8,7 @@ import { UserSection } from "~/actions/sections";
export const inviteUser = createAction({
name: ({ t }) => `${t("Invite people")}`,
icon: <PlusIcon />,
keywords: "team member user",
keywords: "team member workspace user",
section: UserSection,
visible: ({ stores }) =>
stores.policies.abilities(stores.auth.team?.id || "").inviteUser,

View File

@@ -14,5 +14,7 @@ export const NavigationSection = ({ t }: ActionContext) => t("Navigation");
export const UserSection = ({ t }: ActionContext) => t("People");
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
export const RecentSearchesSection = ({ t }: ActionContext) =>
t("Recent searches");

View File

@@ -5,7 +5,7 @@ import { MenuButton, useMenuState } from "reakit/Menu";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import { navigateToSettings, logout } from "~/actions/definitions/navigation";
import { changeTeam } from "~/actions/definitions/teams";
import { createTeam, switchTeamList } from "~/actions/definitions/teams";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePrevious from "~/hooks/usePrevious";
import useSessions from "~/hooks/useSessions";
@@ -34,7 +34,13 @@ const OrganizationMenu: React.FC = ({ children }) => {
// NOTE: it's useful to memoize on the team id and session because the action
// menu is not cached at all.
const actions = React.useMemo(() => {
return [navigateToSettings, separator(), changeTeam, logout];
return [
...switchTeamList,
createTeam,
separator(),
navigateToSettings,
logout,
];
}, [team.id, sessions]);
return (

View File

@@ -213,7 +213,7 @@ function CollectionPermissions({ collectionId }: Props) {
<PermissionExplainer size="small">
{!collection.permission && (
<Trans
defaults="The <em>{{ collectionName }}</em> collection is private. Team members have no access to it by default."
defaults="The <em>{{ collectionName }}</em> collection is private. Workspace members have no access to it by default."
values={{
collectionName,
}}
@@ -224,8 +224,7 @@ function CollectionPermissions({ collectionId }: Props) {
)}
{collection.permission === CollectionPermission.ReadWrite && (
<Trans
defaults="Team members can view and edit documents in the <em>{{ collectionName }}</em> collection by
default."
defaults="Workspace members can view and edit documents in the <em>{{ collectionName }}</em> collection by default."
values={{
collectionName,
}}
@@ -236,7 +235,8 @@ function CollectionPermissions({ collectionId }: Props) {
)}
{collection.permission === CollectionPermission.Read && (
<Trans
defaults="Team members can view documents in the <em>{{ collectionName }}</em> collection by default."
defaults="Workspace members can view documents in the <em>{{ collectionName }}</em> collection by
default."
values={{
collectionName,
}}
@@ -288,9 +288,7 @@ function CollectionPermissions({ collectionId }: Props) {
<Divider />
{isEmpty && (
<Empty>
<Trans>
Add specific access for individual groups and team members
</Trans>
<Trans>Add additional access for individual members and groups</Trans>
</Empty>
)}
<PaginatedList

View File

@@ -165,7 +165,7 @@ function SharePopover({
<SwitchText>
{share?.published
? t("Anyone with the link can view this document")
: t("Only team members with permission can view")}
: t("Only members with permission can view")}
{share?.lastAccessedAt && (
<>
.{" "}
@@ -185,7 +185,7 @@ function SharePopover({
</SwitchWrapper>
) : (
<Text type="secondary">
{t("Only team members with permission can view")}
{t("Only members with permission can view")}
</Text>
)}

View File

@@ -71,7 +71,7 @@ function DocumentReparent({ collection, item, onSubmit, onCancel }: Props) {
<form onSubmit={handleSubmit}>
<Text type="secondary">
<Trans
defaults="Heads up moving the document <em>{{ title }}</em> to the <em>{{ newCollectionName }}</em> collection will grant all team members <em>{{ newPermission }}</em>, they currently have {{ prevPermission }}."
defaults="Heads up moving the document <em>{{ title }}</em> to the <em>{{ newCollectionName }}</em> collection will grant all members of the workspace <em>{{ newPermission }}</em>, they currently have {{ prevPermission }}."
values={{
title: item.title,
prevCollectionName: prevCollection?.name,

View File

@@ -84,7 +84,7 @@ class AddPeopleToGroup extends React.Component<Props> {
<Flex column>
<Text type="secondary">
{t(
"Add team members below to give them access to the group. Need to add someone whos not yet on the team yet?"
"Add members below to give them access to the group. Need to add someone whos not yet a member?"
)}{" "}
<ButtonLink onClick={this.handleInviteModalOpen}>
{t("Invite them to {{teamName}}", {

View File

@@ -59,7 +59,7 @@ function GroupMembers({ group }: Props) {
<>
<Text type="secondary">
<Trans
defaults="Add and remove team members in the <em>{{groupName}}</em> group. Adding people to the group will give them access to any collections this group has been added to."
defaults="Add and remove members to the <em>{{groupName}}</em> group. Members of the group will have access to any collections this group has been added to."
values={{
groupName: group.name,
}}
@@ -82,7 +82,7 @@ function GroupMembers({ group }: Props) {
) : (
<Text type="secondary">
<Trans
defaults="Listing team members in the <em>{{groupName}}</em> group."
defaults="Listing members of the <em>{{groupName}}</em> group."
values={{
groupName: group.name,
}}

View File

@@ -150,7 +150,7 @@ function Invite({ onSubmit }: Props) {
{team.guestSignin ? (
<Text type="secondary">
<Trans
defaults="Invite team members or guests to join your knowledge base. Team members can sign in with {{signinMethods}} or use their email address."
defaults="Invite members or guests to join your workspace. They can sign in with {{signinMethods}} or use their email address."
values={{
signinMethods: team.signinMethods,
}}
@@ -159,7 +159,7 @@ function Invite({ onSubmit }: Props) {
) : (
<Text type="secondary">
<Trans
defaults="Invite team members to join your knowledge base. They will need to sign in with {{signinMethods}}."
defaults="Invite members to join your workspace. They will need to sign in with {{signinMethods}}."
values={{
signinMethods: team.signinMethods,
}}

View File

@@ -185,7 +185,7 @@ function Login({ children }: Props) {
<StyledHeading centered>{t("Create an account")}</StyledHeading>
<GetStarted>
{t(
"Get started by choosing a sign-in method for your new team below…"
"Get started by choosing a sign-in method for your new workspace below…"
)}
</GetStarted>
</>

View File

@@ -123,7 +123,7 @@ function Details() {
label={t("Name")}
name="name"
description={t(
"The team name, usually the same as your company name."
"The workspace name, usually the same as your company name."
)}
>
<Input
@@ -165,7 +165,7 @@ function Details() {
label={t("Start view")}
name="defaultCollectionId"
description={t(
"This is the screen that team members will first see when they sign in."
"This is the screen that workspace members will first see when they sign in."
)}
>
<DefaultCollectionInputSelect

View File

@@ -38,7 +38,7 @@ function Features() {
<Text type="secondary">
<Trans>
Manage optional and beta features. Changing these settings will affect
the experience for all team members.
the experience for all members of the workspace.
</Trans>
</Text>
{team.collaborativeEditing && (

View File

@@ -207,7 +207,7 @@ function Security() {
label={t("Public document sharing")}
name="sharing"
description={t(
"When enabled, documents can be shared publicly on the internet by any team member"
"When enabled, documents can be shared publicly on the internet by any member of the workspace"
)}
>
<Switch id="sharing" checked={data.sharing} onChange={handleChange} />

88
app/scenes/TeamNew.tsx Normal file
View File

@@ -0,0 +1,88 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import User from "~/models/User";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
type Props = {
user: User;
};
function TeamNew({ user }: Props) {
const { auth } = useStores();
const { t } = useTranslation();
const { showToast } = useToasts();
const [name, setName] = React.useState("");
const [isSaving, setIsSaving] = React.useState(false);
const handleSubmit = async (ev: React.SyntheticEvent) => {
ev.preventDefault();
setIsSaving(true);
try {
if (name.trim().length > 1) {
await auth.createTeam({
name: name.trim(),
});
}
} catch (err) {
showToast(err.message, {
type: "error",
});
} finally {
setIsSaving(false);
}
};
const handleNameChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
setName(ev.target.value);
};
return (
<>
<form onSubmit={handleSubmit}>
<Text type="secondary">
<Trans
defaults="Your are creating a new workspace using your current account — <em>{{email}}</em>"
values={{
email: user.email,
}}
components={{
em: <strong />,
}}
/>
</Text>
<Flex>
<Input
type="text"
label={t("Workspace name")}
onChange={handleNameChange}
value={name}
required
autoFocus
flex
/>
</Flex>
<Text type="secondary">
<Trans>
When your new workspace is created, you will be the admin, meaning
you will have the highest level of permissions and the ability to
invite others.
</Trans>
</Text>
<Button type="submit" disabled={isSaving || !(name.trim().length > 1)}>
{isSaving ? `${t("Creating")}` : t("Create")}
</Button>
</form>
</>
);
}
export default observer(TeamNew);

View File

@@ -263,6 +263,20 @@ export default class AuthStore {
}
};
@action
createTeam = async (params: { name: string }) => {
this.isSaving = true;
try {
const res = await client.post(`/teams.create`, params);
invariant(res?.success, "Unable to create team");
window.location.href = res.data.transferUrl;
} finally {
this.isSaving = false;
}
};
@action
logout = async (savePath = false) => {
// if this logout was forced from an authenticated route then