feat: allow ad-hoc creation of new teams (#3964)
Co-authored-by: Tom Moor <tom@getoutline.com>
This commit is contained in:
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 who’s not yet on the team yet?"
|
||||
"Add members below to give them access to the group. Need to add someone who’s not yet a member?"
|
||||
)}{" "}
|
||||
<ButtonLink onClick={this.handleInviteModalOpen}>
|
||||
{t("Invite them to {{teamName}}", {
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
88
app/scenes/TeamNew.tsx
Normal 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);
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user