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 * as React from "react";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
import TeamNew from "~/scenes/TeamNew";
|
||||||
import { createAction } from "~/actions";
|
import { createAction } from "~/actions";
|
||||||
import { loadSessionsFromCookie } from "~/hooks/useSessions";
|
import { loadSessionsFromCookie } from "~/hooks/useSessions";
|
||||||
|
import { TeamSection } from "../sections";
|
||||||
|
|
||||||
export const changeTeam = createAction({
|
export const switchTeamList = getSessions().map((session) => {
|
||||||
name: ({ t }) => t("Switch team"),
|
return createAction({
|
||||||
placeholder: ({ t }) => t("Select a team"),
|
name: session.name,
|
||||||
keywords: "change workspace organization",
|
section: TeamSection,
|
||||||
section: "Account",
|
keywords: "change switch workspace organization team",
|
||||||
visible: ({ currentTeamId }) => {
|
icon: () => <Logo alt={session.name} src={session.logoUrl} />,
|
||||||
const sessions = loadSessionsFromCookie();
|
visible: ({ currentTeamId }) => currentTeamId !== session.teamId,
|
||||||
const otherSessions = sessions.filter(
|
perform: () => (window.location.href = session.url),
|
||||||
(session) => session.teamId !== currentTeamId
|
});
|
||||||
);
|
});
|
||||||
return otherSessions.length > 0;
|
|
||||||
|
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 }) => {
|
perform: ({ t, event, stores }) => {
|
||||||
const sessions = loadSessionsFromCookie();
|
event?.preventDefault();
|
||||||
const otherSessions = sessions.filter(
|
event?.stopPropagation();
|
||||||
(session) => session.teamId !== currentTeamId
|
const { user } = stores.auth;
|
||||||
);
|
user &&
|
||||||
|
stores.dialogs.openModal({
|
||||||
return otherSessions.map((session) => ({
|
title: t("Create a workspace"),
|
||||||
id: session.url,
|
content: <TeamNew user={user} />,
|
||||||
name: session.name,
|
});
|
||||||
section: "Account",
|
|
||||||
icon: <Logo alt={session.name} src={session.logoUrl} />,
|
|
||||||
perform: () => (window.location.href = session.url),
|
|
||||||
}));
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function getSessions(params?: { exclude?: string }) {
|
||||||
|
const sessions = loadSessionsFromCookie();
|
||||||
|
const otherSessions = sessions.filter(
|
||||||
|
(session) => session.teamId !== params?.exclude
|
||||||
|
);
|
||||||
|
return otherSessions;
|
||||||
|
}
|
||||||
|
|
||||||
const Logo = styled("img")`
|
const Logo = styled("img")`
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 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({
|
export const inviteUser = createAction({
|
||||||
name: ({ t }) => `${t("Invite people")}…`,
|
name: ({ t }) => `${t("Invite people")}…`,
|
||||||
icon: <PlusIcon />,
|
icon: <PlusIcon />,
|
||||||
keywords: "team member user",
|
keywords: "team member workspace user",
|
||||||
section: UserSection,
|
section: UserSection,
|
||||||
visible: ({ stores }) =>
|
visible: ({ stores }) =>
|
||||||
stores.policies.abilities(stores.auth.team?.id || "").inviteUser,
|
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 UserSection = ({ t }: ActionContext) => t("People");
|
||||||
|
|
||||||
|
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
|
||||||
|
|
||||||
export const RecentSearchesSection = ({ t }: ActionContext) =>
|
export const RecentSearchesSection = ({ t }: ActionContext) =>
|
||||||
t("Recent searches");
|
t("Recent searches");
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { MenuButton, useMenuState } from "reakit/Menu";
|
|||||||
import ContextMenu from "~/components/ContextMenu";
|
import ContextMenu from "~/components/ContextMenu";
|
||||||
import Template from "~/components/ContextMenu/Template";
|
import Template from "~/components/ContextMenu/Template";
|
||||||
import { navigateToSettings, logout } from "~/actions/definitions/navigation";
|
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 useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||||
import usePrevious from "~/hooks/usePrevious";
|
import usePrevious from "~/hooks/usePrevious";
|
||||||
import useSessions from "~/hooks/useSessions";
|
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
|
// NOTE: it's useful to memoize on the team id and session because the action
|
||||||
// menu is not cached at all.
|
// menu is not cached at all.
|
||||||
const actions = React.useMemo(() => {
|
const actions = React.useMemo(() => {
|
||||||
return [navigateToSettings, separator(), changeTeam, logout];
|
return [
|
||||||
|
...switchTeamList,
|
||||||
|
createTeam,
|
||||||
|
separator(),
|
||||||
|
navigateToSettings,
|
||||||
|
logout,
|
||||||
|
];
|
||||||
}, [team.id, sessions]);
|
}, [team.id, sessions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ function CollectionPermissions({ collectionId }: Props) {
|
|||||||
<PermissionExplainer size="small">
|
<PermissionExplainer size="small">
|
||||||
{!collection.permission && (
|
{!collection.permission && (
|
||||||
<Trans
|
<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={{
|
values={{
|
||||||
collectionName,
|
collectionName,
|
||||||
}}
|
}}
|
||||||
@@ -224,8 +224,7 @@ function CollectionPermissions({ collectionId }: Props) {
|
|||||||
)}
|
)}
|
||||||
{collection.permission === CollectionPermission.ReadWrite && (
|
{collection.permission === CollectionPermission.ReadWrite && (
|
||||||
<Trans
|
<Trans
|
||||||
defaults="Team members can view and edit documents in the <em>{{ collectionName }}</em> collection by
|
defaults="Workspace members can view and edit documents in the <em>{{ collectionName }}</em> collection by default."
|
||||||
default."
|
|
||||||
values={{
|
values={{
|
||||||
collectionName,
|
collectionName,
|
||||||
}}
|
}}
|
||||||
@@ -236,7 +235,8 @@ function CollectionPermissions({ collectionId }: Props) {
|
|||||||
)}
|
)}
|
||||||
{collection.permission === CollectionPermission.Read && (
|
{collection.permission === CollectionPermission.Read && (
|
||||||
<Trans
|
<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={{
|
values={{
|
||||||
collectionName,
|
collectionName,
|
||||||
}}
|
}}
|
||||||
@@ -288,9 +288,7 @@ function CollectionPermissions({ collectionId }: Props) {
|
|||||||
<Divider />
|
<Divider />
|
||||||
{isEmpty && (
|
{isEmpty && (
|
||||||
<Empty>
|
<Empty>
|
||||||
<Trans>
|
<Trans>Add additional access for individual members and groups</Trans>
|
||||||
Add specific access for individual groups and team members
|
|
||||||
</Trans>
|
|
||||||
</Empty>
|
</Empty>
|
||||||
)}
|
)}
|
||||||
<PaginatedList
|
<PaginatedList
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ function SharePopover({
|
|||||||
<SwitchText>
|
<SwitchText>
|
||||||
{share?.published
|
{share?.published
|
||||||
? t("Anyone with the link can view this document")
|
? 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 && (
|
{share?.lastAccessedAt && (
|
||||||
<>
|
<>
|
||||||
.{" "}
|
.{" "}
|
||||||
@@ -185,7 +185,7 @@ function SharePopover({
|
|||||||
</SwitchWrapper>
|
</SwitchWrapper>
|
||||||
) : (
|
) : (
|
||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
{t("Only team members with permission can view")}
|
{t("Only members with permission can view")}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ function DocumentReparent({ collection, item, onSubmit, onCancel }: Props) {
|
|||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
<Trans
|
<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={{
|
values={{
|
||||||
title: item.title,
|
title: item.title,
|
||||||
prevCollectionName: prevCollection?.name,
|
prevCollectionName: prevCollection?.name,
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ class AddPeopleToGroup extends React.Component<Props> {
|
|||||||
<Flex column>
|
<Flex column>
|
||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
{t(
|
{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}>
|
<ButtonLink onClick={this.handleInviteModalOpen}>
|
||||||
{t("Invite them to {{teamName}}", {
|
{t("Invite them to {{teamName}}", {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ function GroupMembers({ group }: Props) {
|
|||||||
<>
|
<>
|
||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
<Trans
|
<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={{
|
values={{
|
||||||
groupName: group.name,
|
groupName: group.name,
|
||||||
}}
|
}}
|
||||||
@@ -82,7 +82,7 @@ function GroupMembers({ group }: Props) {
|
|||||||
) : (
|
) : (
|
||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
<Trans
|
<Trans
|
||||||
defaults="Listing team members in the <em>{{groupName}}</em> group."
|
defaults="Listing members of the <em>{{groupName}}</em> group."
|
||||||
values={{
|
values={{
|
||||||
groupName: group.name,
|
groupName: group.name,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ function Invite({ onSubmit }: Props) {
|
|||||||
{team.guestSignin ? (
|
{team.guestSignin ? (
|
||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
<Trans
|
<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={{
|
values={{
|
||||||
signinMethods: team.signinMethods,
|
signinMethods: team.signinMethods,
|
||||||
}}
|
}}
|
||||||
@@ -159,7 +159,7 @@ function Invite({ onSubmit }: Props) {
|
|||||||
) : (
|
) : (
|
||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
<Trans
|
<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={{
|
values={{
|
||||||
signinMethods: team.signinMethods,
|
signinMethods: team.signinMethods,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ function Login({ children }: Props) {
|
|||||||
<StyledHeading centered>{t("Create an account")}</StyledHeading>
|
<StyledHeading centered>{t("Create an account")}</StyledHeading>
|
||||||
<GetStarted>
|
<GetStarted>
|
||||||
{t(
|
{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>
|
</GetStarted>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ function Details() {
|
|||||||
label={t("Name")}
|
label={t("Name")}
|
||||||
name="name"
|
name="name"
|
||||||
description={t(
|
description={t(
|
||||||
"The team name, usually the same as your company name."
|
"The workspace name, usually the same as your company name."
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
@@ -165,7 +165,7 @@ function Details() {
|
|||||||
label={t("Start view")}
|
label={t("Start view")}
|
||||||
name="defaultCollectionId"
|
name="defaultCollectionId"
|
||||||
description={t(
|
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
|
<DefaultCollectionInputSelect
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function Features() {
|
|||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
<Trans>
|
<Trans>
|
||||||
Manage optional and beta features. Changing these settings will affect
|
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>
|
</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
{team.collaborativeEditing && (
|
{team.collaborativeEditing && (
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ function Security() {
|
|||||||
label={t("Public document sharing")}
|
label={t("Public document sharing")}
|
||||||
name="sharing"
|
name="sharing"
|
||||||
description={t(
|
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} />
|
<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
|
@action
|
||||||
logout = async (savePath = false) => {
|
logout = async (savePath = false) => {
|
||||||
// if this logout was forced from an authenticated route then
|
// if this logout was forced from an authenticated route then
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ describe("accountProvisioner", () => {
|
|||||||
username: "jtester",
|
username: "jtester",
|
||||||
},
|
},
|
||||||
team: {
|
team: {
|
||||||
name: "New team",
|
name: "New workspace",
|
||||||
avatarUrl: "https://example.com/avatar.png",
|
avatarUrl: "https://example.com/avatar.png",
|
||||||
subdomain: "example",
|
subdomain: "example",
|
||||||
},
|
},
|
||||||
@@ -44,7 +44,7 @@ describe("accountProvisioner", () => {
|
|||||||
expect(auth.accessToken).toEqual("123");
|
expect(auth.accessToken).toEqual("123");
|
||||||
expect(auth.scopes.length).toEqual(1);
|
expect(auth.scopes.length).toEqual(1);
|
||||||
expect(auth.scopes[0]).toEqual("read");
|
expect(auth.scopes[0]).toEqual("read");
|
||||||
expect(team.name).toEqual("New team");
|
expect(team.name).toEqual("New workspace");
|
||||||
expect(user.email).toEqual("jenny@example-company.com");
|
expect(user.email).toEqual("jenny@example-company.com");
|
||||||
expect(user.username).toEqual("jtester");
|
expect(user.username).toEqual("jtester");
|
||||||
expect(isNewUser).toEqual(true);
|
expect(isNewUser).toEqual(true);
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import invariant from "invariant";
|
import invariant from "invariant";
|
||||||
import { UniqueConstraintError } from "sequelize";
|
|
||||||
import WelcomeEmail from "@server/emails/templates/WelcomeEmail";
|
import WelcomeEmail from "@server/emails/templates/WelcomeEmail";
|
||||||
import {
|
import {
|
||||||
AuthenticationError,
|
AuthenticationError,
|
||||||
InvalidAuthenticationError,
|
InvalidAuthenticationError,
|
||||||
EmailAuthenticationRequiredError,
|
|
||||||
AuthenticationProviderDisabledError,
|
AuthenticationProviderDisabledError,
|
||||||
} from "@server/errors";
|
} from "@server/errors";
|
||||||
import { APM } from "@server/logging/tracing";
|
import { APM } from "@server/logging/tracing";
|
||||||
@@ -181,25 +179,7 @@ async function accountProvisioner({
|
|||||||
isNewTeam,
|
isNewTeam,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof UniqueConstraintError) {
|
throw AuthenticationError(err.message);
|
||||||
const exists = await User.findOne({
|
|
||||||
where: {
|
|
||||||
email: userParams.email,
|
|
||||||
teamId: team.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (exists) {
|
|
||||||
throw EmailAuthenticationRequiredError(
|
|
||||||
"Email authentication required",
|
|
||||||
team.url
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
throw AuthenticationError(err.message, team.url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
110
server/commands/teamCreator.ts
Normal file
110
server/commands/teamCreator.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { Transaction } from "sequelize";
|
||||||
|
import slugify from "slugify";
|
||||||
|
import { RESERVED_SUBDOMAINS } from "@shared/utils/domains";
|
||||||
|
import { APM } from "@server/logging/tracing";
|
||||||
|
import { Team, Event } from "@server/models";
|
||||||
|
import { generateAvatarUrl } from "@server/utils/avatars";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** The displayed name of the team */
|
||||||
|
name: string;
|
||||||
|
/** The domain name from the email of the user logging in */
|
||||||
|
domain?: string;
|
||||||
|
/** The preferred subdomain to provision for the team if not yet created */
|
||||||
|
subdomain: string;
|
||||||
|
/** The public url of an image representing the team */
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
/** Details of the authentication provider being used */
|
||||||
|
authenticationProviders: {
|
||||||
|
/** The name of the authentication provider, eg "google" */
|
||||||
|
name: string;
|
||||||
|
/** External identifier of the authentication provider */
|
||||||
|
providerId: string;
|
||||||
|
}[];
|
||||||
|
/** The IP address of the incoming request */
|
||||||
|
ip: string;
|
||||||
|
/** Optional transaction to be chained from outside */
|
||||||
|
transaction: Transaction;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function teamCreator({
|
||||||
|
name,
|
||||||
|
domain,
|
||||||
|
subdomain,
|
||||||
|
avatarUrl,
|
||||||
|
authenticationProviders,
|
||||||
|
ip,
|
||||||
|
transaction,
|
||||||
|
}: Props): Promise<Team> {
|
||||||
|
// If the service did not provide a logo/avatar then we attempt to generate
|
||||||
|
// one via ClearBit, or fallback to colored initials in worst case scenario
|
||||||
|
if (!avatarUrl) {
|
||||||
|
avatarUrl = await generateAvatarUrl({
|
||||||
|
name,
|
||||||
|
domain,
|
||||||
|
id: subdomain,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const team = await Team.create(
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
avatarUrl,
|
||||||
|
authenticationProviders,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
include: ["authenticationProviders"],
|
||||||
|
transaction,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await Event.create(
|
||||||
|
{
|
||||||
|
name: "teams.create",
|
||||||
|
teamId: team.id,
|
||||||
|
ip,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
transaction,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const availableSubdomain = await findAvailableSubdomain(team, subdomain);
|
||||||
|
await team.update({ subdomain: availableSubdomain }, { transaction });
|
||||||
|
|
||||||
|
return team;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findAvailableSubdomain(team: Team, requestedSubdomain: string) {
|
||||||
|
// filter subdomain to only valid characters
|
||||||
|
// if there are less than the minimum length, use a default subdomain
|
||||||
|
const normalizedSubdomain = slugify(requestedSubdomain, {
|
||||||
|
lower: true,
|
||||||
|
strict: true,
|
||||||
|
});
|
||||||
|
let subdomain =
|
||||||
|
normalizedSubdomain.length < 3 ||
|
||||||
|
RESERVED_SUBDOMAINS.includes(normalizedSubdomain)
|
||||||
|
? "team"
|
||||||
|
: normalizedSubdomain;
|
||||||
|
|
||||||
|
let append = 0;
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const existing = await Team.findOne({ where: { subdomain } });
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// subdomain was invalid or already used, try another
|
||||||
|
subdomain = `${normalizedSubdomain}${++append}`;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return subdomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default APM.traceFunction({
|
||||||
|
serviceName: "command",
|
||||||
|
spanName: "teamCreator",
|
||||||
|
})(teamCreator);
|
||||||
@@ -35,6 +35,7 @@ describe("teamProvisioner", () => {
|
|||||||
await buildTeam({
|
await buildTeam({
|
||||||
subdomain: "myteam",
|
subdomain: "myteam",
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await teamProvisioner({
|
const result = await teamProvisioner({
|
||||||
name: "Test team",
|
name: "Test team",
|
||||||
subdomain: "myteam",
|
subdomain: "myteam",
|
||||||
@@ -46,6 +47,7 @@ describe("teamProvisioner", () => {
|
|||||||
ip,
|
ip,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(result.isNewTeam).toEqual(true);
|
||||||
expect(result.team.subdomain).toEqual("myteam1");
|
expect(result.team.subdomain).toEqual("myteam1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import teamCreator from "@server/commands/teamCreator";
|
||||||
import { sequelize } from "@server/database/sequelize";
|
import { sequelize } from "@server/database/sequelize";
|
||||||
import env from "@server/env";
|
import env from "@server/env";
|
||||||
import {
|
import {
|
||||||
@@ -5,10 +6,8 @@ import {
|
|||||||
InvalidAuthenticationError,
|
InvalidAuthenticationError,
|
||||||
MaximumTeamsError,
|
MaximumTeamsError,
|
||||||
} from "@server/errors";
|
} from "@server/errors";
|
||||||
import Logger from "@server/logging/Logger";
|
|
||||||
import { APM } from "@server/logging/tracing";
|
import { APM } from "@server/logging/tracing";
|
||||||
import { Team, AuthenticationProvider, Event } from "@server/models";
|
import { Team, AuthenticationProvider } from "@server/models";
|
||||||
import { generateAvatarUrl } from "@server/utils/avatars";
|
|
||||||
|
|
||||||
type TeamProvisionerResult = {
|
type TeamProvisionerResult = {
|
||||||
team: Team;
|
team: Team;
|
||||||
@@ -106,54 +105,18 @@ async function teamProvisioner({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the service did not provide a logo/avatar then we attempt to generate
|
// We cannot find an existing team, so we create a new one
|
||||||
// one via ClearBit, or fallback to colored initials in worst case scenario
|
const team = await sequelize.transaction((transaction) => {
|
||||||
if (!avatarUrl) {
|
return teamCreator({
|
||||||
avatarUrl = await generateAvatarUrl({
|
|
||||||
name,
|
name,
|
||||||
domain,
|
domain,
|
||||||
id: subdomain,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const team = await sequelize.transaction(async (transaction) => {
|
|
||||||
const team = await Team.create(
|
|
||||||
{
|
|
||||||
name,
|
|
||||||
avatarUrl,
|
|
||||||
authenticationProviders: [authenticationProvider],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
include: "authenticationProviders",
|
|
||||||
transaction,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
await Event.create(
|
|
||||||
{
|
|
||||||
name: "teams.create",
|
|
||||||
teamId: team.id,
|
|
||||||
ip,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
transaction,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return team;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Note provisioning the subdomain is done outside of the transaction as
|
|
||||||
// it is allowed to fail and the team can still be created, it also requires
|
|
||||||
// failed queries as part of iteration
|
|
||||||
try {
|
|
||||||
await provisionSubdomain(team, subdomain);
|
|
||||||
} catch (err) {
|
|
||||||
Logger.error("Provisioning subdomain failed", err, {
|
|
||||||
teamId: team.id,
|
|
||||||
subdomain,
|
subdomain,
|
||||||
|
avatarUrl,
|
||||||
|
authenticationProviders: [authenticationProvider],
|
||||||
|
ip,
|
||||||
|
transaction,
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
team,
|
team,
|
||||||
@@ -162,28 +125,6 @@ async function teamProvisioner({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function provisionSubdomain(team: Team, requestedSubdomain: string) {
|
|
||||||
if (team.subdomain) {
|
|
||||||
return team.subdomain;
|
|
||||||
}
|
|
||||||
let subdomain = requestedSubdomain;
|
|
||||||
let append = 0;
|
|
||||||
|
|
||||||
for (;;) {
|
|
||||||
try {
|
|
||||||
await team.update({
|
|
||||||
subdomain,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
} catch (err) {
|
|
||||||
// subdomain was invalid or already used, try again
|
|
||||||
subdomain = `${requestedSubdomain}${++append}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return subdomain;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default APM.traceFunction({
|
export default APM.traceFunction({
|
||||||
serviceName: "command",
|
serviceName: "command",
|
||||||
spanName: "teamProvisioner",
|
spanName: "teamProvisioner",
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up (queryInterface, Sequelize) {
|
||||||
|
await queryInterface.removeConstraint("authentication_providers", "authentication_providers_providerId_key");
|
||||||
|
await queryInterface.addConstraint("authentication_providers", {
|
||||||
|
type: 'unique',
|
||||||
|
fields: ["providerId", "teamId"],
|
||||||
|
name: "authentication_providers_providerId_teamId_uk"
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async down (queryInterface, Sequelize) {
|
||||||
|
await queryInterface.removeConstraint("authentication_providers", "authentication_providers_providerId_teamId_uk");
|
||||||
|
await queryInterface.addConstraint("authentication_providers", {
|
||||||
|
type: 'unique',
|
||||||
|
fields: ["providerId"],
|
||||||
|
name: "authentication_providers_providerId_key"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up (queryInterface, Sequelize) {
|
||||||
|
await queryInterface.removeConstraint("user_authentications", "user_authentications_providerId_key");
|
||||||
|
await queryInterface.addConstraint("user_authentications", {
|
||||||
|
type: 'unique',
|
||||||
|
fields: ["providerId", "userId"],
|
||||||
|
name: "user_authentications_providerId_userId_uk"
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async down (queryInterface, Sequelize) {
|
||||||
|
await queryInterface.removeConstraint("user_authentications", "user_authentications_providerId_userId_uk");
|
||||||
|
await queryInterface.addConstraint("user_authentications", {
|
||||||
|
type: 'unique',
|
||||||
|
fields: ["providerId"],
|
||||||
|
name: "user_authentications_providerId_key"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -55,7 +55,7 @@ export type TeamPreferences = Record<string, unknown>;
|
|||||||
@Fix
|
@Fix
|
||||||
class Team extends ParanoidModel {
|
class Team extends ParanoidModel {
|
||||||
@NotContainsUrl
|
@NotContainsUrl
|
||||||
@Length({ max: 255, msg: "name must be 255 characters or less" })
|
@Length({ min: 2, max: 255, msg: "name must be between 2 to 255 characters" })
|
||||||
@Column
|
@Column
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import env from "@server/env";
|
||||||
import { buildUser, buildTeam, buildAdmin } from "@server/test/factories";
|
import { buildUser, buildTeam, buildAdmin } from "@server/test/factories";
|
||||||
import { setupTestDatabase } from "@server/test/support";
|
import { setupTestDatabase } from "@server/test/support";
|
||||||
import { serialize } from "./index";
|
import { serialize } from "./index";
|
||||||
@@ -12,6 +13,7 @@ it("should allow reading only", async () => {
|
|||||||
const abilities = serialize(user, team);
|
const abilities = serialize(user, team);
|
||||||
expect(abilities.read).toEqual(true);
|
expect(abilities.read).toEqual(true);
|
||||||
expect(abilities.manage).toEqual(false);
|
expect(abilities.manage).toEqual(false);
|
||||||
|
expect(abilities.createTeam).toEqual(false);
|
||||||
expect(abilities.createAttachment).toEqual(true);
|
expect(abilities.createAttachment).toEqual(true);
|
||||||
expect(abilities.createCollection).toEqual(true);
|
expect(abilities.createCollection).toEqual(true);
|
||||||
expect(abilities.createDocument).toEqual(true);
|
expect(abilities.createDocument).toEqual(true);
|
||||||
@@ -27,6 +29,25 @@ it("should allow admins to manage", async () => {
|
|||||||
const abilities = serialize(admin, team);
|
const abilities = serialize(admin, team);
|
||||||
expect(abilities.read).toEqual(true);
|
expect(abilities.read).toEqual(true);
|
||||||
expect(abilities.manage).toEqual(true);
|
expect(abilities.manage).toEqual(true);
|
||||||
|
expect(abilities.createTeam).toEqual(false);
|
||||||
|
expect(abilities.createAttachment).toEqual(true);
|
||||||
|
expect(abilities.createCollection).toEqual(true);
|
||||||
|
expect(abilities.createDocument).toEqual(true);
|
||||||
|
expect(abilities.createGroup).toEqual(true);
|
||||||
|
expect(abilities.createIntegration).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow creation on hosted envs", async () => {
|
||||||
|
env.DEPLOYMENT = "hosted";
|
||||||
|
|
||||||
|
const team = await buildTeam();
|
||||||
|
const admin = await buildAdmin({
|
||||||
|
teamId: team.id,
|
||||||
|
});
|
||||||
|
const abilities = serialize(admin, team);
|
||||||
|
expect(abilities.read).toEqual(true);
|
||||||
|
expect(abilities.manage).toEqual(true);
|
||||||
|
expect(abilities.createTeam).toEqual(true);
|
||||||
expect(abilities.createAttachment).toEqual(true);
|
expect(abilities.createAttachment).toEqual(true);
|
||||||
expect(abilities.createCollection).toEqual(true);
|
expect(abilities.createCollection).toEqual(true);
|
||||||
expect(abilities.createDocument).toEqual(true);
|
expect(abilities.createDocument).toEqual(true);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import env from "@server/env";
|
||||||
import { Team, User } from "@server/models";
|
import { Team, User } from "@server/models";
|
||||||
import { allow } from "./cancan";
|
import { allow } from "./cancan";
|
||||||
|
|
||||||
@@ -10,6 +11,12 @@ allow(User, "share", Team, (user, team) => {
|
|||||||
return team.sharing;
|
return team.sharing;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
allow(User, "createTeam", Team, () => {
|
||||||
|
if (env.DEPLOYMENT !== "hosted") {
|
||||||
|
throw "createTeam only available on cloud";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
allow(User, ["update", "manage"], Team, (user, team) => {
|
allow(User, ["update", "manage"], Team, (user, team) => {
|
||||||
if (!team || user.isViewer || user.teamId !== team.id) {
|
if (!team || user.isViewer || user.teamId !== team.id) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -73,21 +73,20 @@ describe("#authenticationProviders.update", () => {
|
|||||||
const user = await buildAdmin({
|
const user = await buildAdmin({
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
});
|
});
|
||||||
await team.$create("authenticationProvider", {
|
const googleProvider = await team.$create("authenticationProvider", {
|
||||||
name: "google",
|
name: "google",
|
||||||
providerId: uuidv4(),
|
providerId: uuidv4(),
|
||||||
});
|
});
|
||||||
const authenticationProviders = await team.$get("authenticationProviders");
|
|
||||||
const res = await server.post("/api/authenticationProviders.update", {
|
const res = await server.post("/api/authenticationProviders.update", {
|
||||||
body: {
|
body: {
|
||||||
id: authenticationProviders[0].id,
|
id: googleProvider.id,
|
||||||
isEnabled: false,
|
isEnabled: false,
|
||||||
token: user.getJwtToken(),
|
token: user.getJwtToken(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(res.status).toEqual(200);
|
expect(res.status).toEqual(200);
|
||||||
expect(body.data.name).toBe("slack");
|
expect(body.data.name).toBe("google");
|
||||||
expect(body.data.isEnabled).toBe(false);
|
expect(body.data.isEnabled).toBe(false);
|
||||||
expect(body.data.isConnected).toBe(true);
|
expect(body.data.isConnected).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ router.post("authenticationProviders.update", auth(), async (ctx) => {
|
|||||||
assertPresent(isEnabled, "isEnabled is required");
|
assertPresent(isEnabled, "isEnabled is required");
|
||||||
const { user } = ctx.state;
|
const { user } = ctx.state;
|
||||||
const authenticationProvider = await AuthenticationProvider.findByPk(id);
|
const authenticationProvider = await AuthenticationProvider.findByPk(id);
|
||||||
|
|
||||||
authorize(user, "update", authenticationProvider);
|
authorize(user, "update", authenticationProvider);
|
||||||
const enabled = !!isEnabled;
|
const enabled = !!isEnabled;
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,41 @@
|
|||||||
|
import env from "@server/env";
|
||||||
import { TeamDomain } from "@server/models";
|
import { TeamDomain } from "@server/models";
|
||||||
import { buildAdmin, buildCollection, buildTeam } from "@server/test/factories";
|
import { buildAdmin, buildCollection, buildTeam } from "@server/test/factories";
|
||||||
import { seed, getTestServer } from "@server/test/support";
|
import { seed, getTestServer } from "@server/test/support";
|
||||||
|
|
||||||
const server = getTestServer();
|
const server = getTestServer();
|
||||||
|
|
||||||
|
describe("teams.create", () => {
|
||||||
|
it("creates a team", async () => {
|
||||||
|
env.DEPLOYMENT = "hosted";
|
||||||
|
const team = await buildTeam();
|
||||||
|
const user = await buildAdmin({ teamId: team.id });
|
||||||
|
const res = await server.post("/api/teams.create", {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
name: "new workspace",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.team.name).toEqual("new workspace");
|
||||||
|
expect(body.data.team.subdomain).toEqual("new-workspace");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a cloud hosted deployment", async () => {
|
||||||
|
env.DEPLOYMENT = "self-hosted";
|
||||||
|
const team = await buildTeam();
|
||||||
|
const user = await buildAdmin({ teamId: team.id });
|
||||||
|
const res = await server.post("/api/teams.create", {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
name: "new workspace",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.status).toEqual(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("#team.update", () => {
|
describe("#team.update", () => {
|
||||||
it("should update team details", async () => {
|
it("should update team details", async () => {
|
||||||
const { admin } = await seed();
|
const { admin } = await seed();
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import invariant from "invariant";
|
||||||
import Router from "koa-router";
|
import Router from "koa-router";
|
||||||
import { RateLimiterStrategy } from "@server/RateLimiter";
|
import { RateLimiterStrategy } from "@server/RateLimiter";
|
||||||
|
import teamCreator from "@server/commands/teamCreator";
|
||||||
import teamUpdater from "@server/commands/teamUpdater";
|
import teamUpdater from "@server/commands/teamUpdater";
|
||||||
|
import { sequelize } from "@server/database/sequelize";
|
||||||
import auth from "@server/middlewares/authentication";
|
import auth from "@server/middlewares/authentication";
|
||||||
import { rateLimiter } from "@server/middlewares/rateLimiter";
|
import { rateLimiter } from "@server/middlewares/rateLimiter";
|
||||||
import { Team, TeamDomain } from "@server/models";
|
import { Event, Team, TeamDomain, User } from "@server/models";
|
||||||
import { authorize } from "@server/policies";
|
import { authorize } from "@server/policies";
|
||||||
import { presentTeam, presentPolicies } from "@server/presenters";
|
import { presentTeam, presentPolicies } from "@server/presenters";
|
||||||
import { assertUuid } from "@server/validation";
|
import { assertUuid } from "@server/validation";
|
||||||
@@ -69,4 +72,83 @@ router.post(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"teams.create",
|
||||||
|
auth(),
|
||||||
|
rateLimiter(RateLimiterStrategy.FivePerHour),
|
||||||
|
async (ctx) => {
|
||||||
|
const { user } = ctx.state;
|
||||||
|
const { name } = ctx.body;
|
||||||
|
|
||||||
|
const existingTeam = await Team.scope(
|
||||||
|
"withAuthenticationProviders"
|
||||||
|
).findByPk(user.teamId, {
|
||||||
|
rejectOnEmpty: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
authorize(user, "createTeam", existingTeam);
|
||||||
|
|
||||||
|
const authenticationProviders = existingTeam.authenticationProviders.map(
|
||||||
|
(provider) => {
|
||||||
|
return {
|
||||||
|
name: provider.name,
|
||||||
|
providerId: provider.providerId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
invariant(
|
||||||
|
authenticationProviders?.length,
|
||||||
|
"Team must have at least one authentication provider"
|
||||||
|
);
|
||||||
|
|
||||||
|
const [team, newUser] = await sequelize.transaction(async (transaction) => {
|
||||||
|
const team = await teamCreator({
|
||||||
|
name,
|
||||||
|
subdomain: name,
|
||||||
|
authenticationProviders,
|
||||||
|
ip: ctx.ip,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newUser = await User.create(
|
||||||
|
{
|
||||||
|
teamId: team.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
isAdmin: true,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
},
|
||||||
|
{ transaction }
|
||||||
|
);
|
||||||
|
|
||||||
|
await Event.create(
|
||||||
|
{
|
||||||
|
name: "users.create",
|
||||||
|
actorId: user.id,
|
||||||
|
userId: newUser.id,
|
||||||
|
teamId: newUser.teamId,
|
||||||
|
data: {
|
||||||
|
name: newUser.name,
|
||||||
|
},
|
||||||
|
ip: ctx.ip,
|
||||||
|
},
|
||||||
|
{ transaction }
|
||||||
|
);
|
||||||
|
|
||||||
|
return [team, newUser];
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
team: presentTeam(team),
|
||||||
|
transferUrl: `${
|
||||||
|
team.url
|
||||||
|
}/auth/redirect?token=${newUser?.getTransferToken()}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -228,7 +228,6 @@ export default class ListItem extends Node {
|
|||||||
!$pos.nodeBefore ||
|
!$pos.nodeBefore ||
|
||||||
!["list_item", "checkbox_item"].includes($pos.nodeBefore.type.name)
|
!["list_item", "checkbox_item"].includes($pos.nodeBefore.type.name)
|
||||||
) {
|
) {
|
||||||
console.log("Node before not a list item");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,7 +258,6 @@ export default class ListItem extends Node {
|
|||||||
!$pos.nodeAfter ||
|
!$pos.nodeAfter ||
|
||||||
!["list_item", "checkbox_item"].includes($pos.nodeAfter.type.name)
|
!["list_item", "checkbox_item"].includes($pos.nodeAfter.type.name)
|
||||||
) {
|
) {
|
||||||
console.log("Node after not a list item");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,8 +67,10 @@
|
|||||||
"Appearance": "Appearance",
|
"Appearance": "Appearance",
|
||||||
"Change theme": "Change theme",
|
"Change theme": "Change theme",
|
||||||
"Change theme to": "Change theme to",
|
"Change theme to": "Change theme to",
|
||||||
"Switch team": "Switch team",
|
"Switch workspace": "Switch workspace",
|
||||||
"Select a team": "Select a team",
|
"Select a workspace": "Select a workspace",
|
||||||
|
"New workspace": "New workspace",
|
||||||
|
"Create a workspace": "Create a workspace",
|
||||||
"Invite people": "Invite people",
|
"Invite people": "Invite people",
|
||||||
"Collection": "Collection",
|
"Collection": "Collection",
|
||||||
"Debug": "Debug",
|
"Debug": "Debug",
|
||||||
@@ -76,6 +78,7 @@
|
|||||||
"Revision": "Revision",
|
"Revision": "Revision",
|
||||||
"Navigation": "Navigation",
|
"Navigation": "Navigation",
|
||||||
"People": "People",
|
"People": "People",
|
||||||
|
"Workspace": "Workspace",
|
||||||
"Recent searches": "Recent searches",
|
"Recent searches": "Recent searches",
|
||||||
"currently editing": "currently editing",
|
"currently editing": "currently editing",
|
||||||
"currently viewing": "currently viewing",
|
"currently viewing": "currently viewing",
|
||||||
@@ -84,7 +87,7 @@
|
|||||||
"Viewers": "Viewers",
|
"Viewers": "Viewers",
|
||||||
"I’m sure – Delete": "I’m sure – Delete",
|
"I’m sure – Delete": "I’m sure – Delete",
|
||||||
"Deleting": "Deleting",
|
"Deleting": "Deleting",
|
||||||
"Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.": "Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.",
|
"Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.": "Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.",
|
||||||
"Also, <em>{{collectionName}}</em> is being used as the start view – deleting it will reset the start view to the Home page.": "Also, <em>{{collectionName}}</em> is being used as the start view – deleting it will reset the start view to the Home page.",
|
"Also, <em>{{collectionName}}</em> is being used as the start view – deleting it will reset the start view to the Home page.": "Also, <em>{{collectionName}}</em> is being used as the start view – deleting it will reset the start view to the Home page.",
|
||||||
"Sorry, an error occurred saving the collection": "Sorry, an error occurred saving the collection",
|
"Sorry, an error occurred saving the collection": "Sorry, an error occurred saving the collection",
|
||||||
"Add a description": "Add a description",
|
"Add a description": "Add a description",
|
||||||
@@ -122,15 +125,15 @@
|
|||||||
"Viewed": "Viewed",
|
"Viewed": "Viewed",
|
||||||
"in": "in",
|
"in": "in",
|
||||||
"nested document": "nested document",
|
"nested document": "nested document",
|
||||||
"nested document_plural": "nested documents",
|
"nested document_plural": "nested document",
|
||||||
"Viewed by": "Viewed by",
|
"Viewed by": "Viewed by",
|
||||||
"only you": "only you",
|
"only you": "only you",
|
||||||
"person": "person",
|
"person": "person",
|
||||||
"people": "people",
|
"people": "people",
|
||||||
"{{ total }} task": "{{ total }} task",
|
"{{ total }} task": "{{ total }} task",
|
||||||
"{{ total }} task_plural": "{{ total }} tasks",
|
"{{ total }} task_plural": "{{ total }} task",
|
||||||
"{{ completed }} task done": "{{ completed }} task done",
|
"{{ completed }} task done": "{{ completed }} task done",
|
||||||
"{{ completed }} task done_plural": "{{ completed }} tasks done",
|
"{{ completed }} task done_plural": "{{ completed }} task done",
|
||||||
"{{ completed }} of {{ total }} tasks": "{{ completed }} of {{ total }} tasks",
|
"{{ completed }} of {{ total }} tasks": "{{ completed }} of {{ total }} tasks",
|
||||||
"Template created, go ahead and customize it": "Template created, go ahead and customize it",
|
"Template created, go ahead and customize it": "Template created, go ahead and customize it",
|
||||||
"Creating": "Creating",
|
"Creating": "Creating",
|
||||||
@@ -193,9 +196,9 @@
|
|||||||
"Show more": "Show more",
|
"Show more": "Show more",
|
||||||
"Toggle sidebar": "Toggle sidebar",
|
"Toggle sidebar": "Toggle sidebar",
|
||||||
"Up to date": "Up to date",
|
"Up to date": "Up to date",
|
||||||
"{{ releasesBehind }} versions behind": "{{ releasesBehind }} version behind",
|
"{{ releasesBehind }} versions behind": "{{ releasesBehind }} versions behind",
|
||||||
"{{ releasesBehind }} versions behind_plural": "{{ releasesBehind }} versions behind",
|
"{{ releasesBehind }} versions behind_plural": "{{ releasesBehind }} versions behind",
|
||||||
"Return to App": "Back to App",
|
"Return to App": "Return to App",
|
||||||
"Installation": "Installation",
|
"Installation": "Installation",
|
||||||
"No results": "No results",
|
"No results": "No results",
|
||||||
"Previous page": "Previous page",
|
"Previous page": "Previous page",
|
||||||
@@ -223,7 +226,7 @@
|
|||||||
"Align left": "Align left",
|
"Align left": "Align left",
|
||||||
"Align right": "Align right",
|
"Align right": "Align right",
|
||||||
"Bulleted list": "Bulleted list",
|
"Bulleted list": "Bulleted list",
|
||||||
"Todo list": "Task list",
|
"Todo list": "Todo list",
|
||||||
"Code block": "Code block",
|
"Code block": "Code block",
|
||||||
"Copied to clipboard": "Copied to clipboard",
|
"Copied to clipboard": "Copied to clipboard",
|
||||||
"Code": "Code",
|
"Code": "Code",
|
||||||
@@ -289,7 +292,7 @@
|
|||||||
"Group member options": "Group member options",
|
"Group member options": "Group member options",
|
||||||
"Remove": "Remove",
|
"Remove": "Remove",
|
||||||
"Export collection": "Export collection",
|
"Export collection": "Export collection",
|
||||||
"Delete collection": "Are you sure you want to delete this collection?",
|
"Delete collection": "Delete collection",
|
||||||
"Sort in sidebar": "Sort in sidebar",
|
"Sort in sidebar": "Sort in sidebar",
|
||||||
"Alphabetical sort": "Alphabetical sort",
|
"Alphabetical sort": "Alphabetical sort",
|
||||||
"Manual sort": "Manual sort",
|
"Manual sort": "Manual sort",
|
||||||
@@ -401,15 +404,15 @@
|
|||||||
"Could not update permissions": "Could not update permissions",
|
"Could not update permissions": "Could not update permissions",
|
||||||
"Public document sharing permissions were updated": "Public document sharing permissions were updated",
|
"Public document sharing permissions were updated": "Public document sharing permissions were updated",
|
||||||
"Could not update public document sharing": "Could not update public document sharing",
|
"Could not update public document sharing": "Could not update public document sharing",
|
||||||
"The <em>{{ collectionName }}</em> collection is private. Team members have no access to it by default.": "The <em>{{ collectionName }}</em> collection is private. Team members have no access to it by default.",
|
"The <em>{{ collectionName }}</em> collection is private. Workspace members have no access to it by default.": "The <em>{{ collectionName }}</em> collection is private. Workspace members have no access to it by default.",
|
||||||
"Team members can view and edit documents in the <em>{{ collectionName }}</em> collection by\n default.": "Team members can view and edit documents in the <em>{{ collectionName }}</em> collection by\n default.",
|
"Workspace members can view and edit documents in the <em>{{ collectionName }}</em> collection by default.": "Workspace members can view and edit documents in the <em>{{ collectionName }}</em> collection by default.",
|
||||||
"Team members can view documents in the <em>{{ collectionName }}</em> collection by default.": "Team members can view documents in the <em>{{ collectionName }}</em> collection by default.",
|
"Workspace members can view documents in the <em>{{ collectionName }}</em> collection by\n default.": "Workspace members can view documents in the <em>{{ collectionName }}</em> collection by\n default.",
|
||||||
"When enabled, documents can be shared publicly on the internet.": "When enabled, documents can be shared publicly on the internet.",
|
"When enabled, documents can be shared publicly on the internet.": "When enabled, documents can be shared publicly on the internet.",
|
||||||
"Public sharing is currently disabled in the team security settings.": "Public sharing is currently disabled in the team security settings.",
|
"Public sharing is currently disabled in the team security settings.": "Public sharing is currently disabled in the team security settings.",
|
||||||
"Additional access": "Additional access",
|
"Additional access": "Additional access",
|
||||||
"Add groups": "Add groups",
|
"Add groups": "Add groups",
|
||||||
"Add people": "Add people",
|
"Add people": "Add people",
|
||||||
"Add specific access for individual groups and team members": "Add specific access for individual groups and team members",
|
"Add additional access for individual members and groups": "Add additional access for individual members and groups",
|
||||||
"Add groups to {{ collectionName }}": "Add groups to {{ collectionName }}",
|
"Add groups to {{ collectionName }}": "Add groups to {{ collectionName }}",
|
||||||
"Add people to {{ collectionName }}": "Add people to {{ collectionName }}",
|
"Add people to {{ collectionName }}": "Add people to {{ collectionName }}",
|
||||||
"Document updated by {{userName}}": "Document updated by {{userName}}",
|
"Document updated by {{userName}}": "Document updated by {{userName}}",
|
||||||
@@ -444,7 +447,7 @@
|
|||||||
"This document is shared because the parent <em>{{ documentTitle }}</em> is publicly shared": "This document is shared because the parent <em>{{ documentTitle }}</em> is publicly shared",
|
"This document is shared because the parent <em>{{ documentTitle }}</em> is publicly shared": "This document is shared because the parent <em>{{ documentTitle }}</em> is publicly shared",
|
||||||
"Publish to internet": "Publish to internet",
|
"Publish to internet": "Publish to internet",
|
||||||
"Anyone with the link can view this document": "Anyone with the link can view this document",
|
"Anyone with the link can view this document": "Anyone with the link can view this document",
|
||||||
"Only team members with permission can view": "Only team members with permission can view",
|
"Only members with permission can view": "Only members with permission can view",
|
||||||
"The shared link was last accessed {{ timeAgo }}.": "The shared link was last accessed {{ timeAgo }}.",
|
"The shared link was last accessed {{ timeAgo }}.": "The shared link was last accessed {{ timeAgo }}.",
|
||||||
"Share nested documents": "Share nested documents",
|
"Share nested documents": "Share nested documents",
|
||||||
"Nested documents are publicly available": "Nested documents are publicly available",
|
"Nested documents are publicly available": "Nested documents are publicly available",
|
||||||
@@ -456,8 +459,8 @@
|
|||||||
"{{ teamName }} is using Outline to share documents, please login to continue.": "{{ teamName }} is using Outline to share documents, please login to continue.",
|
"{{ teamName }} is using Outline to share documents, please login to continue.": "{{ teamName }} is using Outline to share documents, please login to continue.",
|
||||||
"Are you sure you want to delete the <em>{{ documentTitle }}</em> template?": "Are you sure you want to delete the <em>{{ documentTitle }}</em> template?",
|
"Are you sure you want to delete the <em>{{ documentTitle }}</em> template?": "Are you sure you want to delete the <em>{{ documentTitle }}</em> template?",
|
||||||
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history</em>.": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history</em>.",
|
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history</em>.": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history</em>.",
|
||||||
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>.": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>one nested document</em>.",
|
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>.": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>.",
|
||||||
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>._plural": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested documents</em>.",
|
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>._plural": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>.",
|
||||||
"If you’d like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.": "If you’d like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.",
|
"If you’d like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.": "If you’d like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.",
|
||||||
"Archiving": "Archiving",
|
"Archiving": "Archiving",
|
||||||
"Document moved": "Document moved",
|
"Document moved": "Document moved",
|
||||||
@@ -470,7 +473,7 @@
|
|||||||
"view and edit access": "view and edit access",
|
"view and edit access": "view and edit access",
|
||||||
"view only access": "view only access",
|
"view only access": "view only access",
|
||||||
"no access": "no access",
|
"no access": "no access",
|
||||||
"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 }}.": "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 }}.",
|
"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 }}.": "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 }}.",
|
||||||
"Moving": "Moving",
|
"Moving": "Moving",
|
||||||
"Cancel": "Cancel",
|
"Cancel": "Cancel",
|
||||||
"Search documents": "Search documents",
|
"Search documents": "Search documents",
|
||||||
@@ -486,11 +489,11 @@
|
|||||||
"Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.": "Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.",
|
"Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.": "Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.",
|
||||||
"You can edit the name of this group at any time, however doing so too often might confuse your team mates.": "You can edit the name of this group at any time, however doing so too often might confuse your team mates.",
|
"You can edit the name of this group at any time, however doing so too often might confuse your team mates.": "You can edit the name of this group at any time, however doing so too often might confuse your team mates.",
|
||||||
"{{userName}} was added to the group": "{{userName}} was added to the group",
|
"{{userName}} was added to the group": "{{userName}} was added to the group",
|
||||||
"Add team members below to give them access to the group. Need to add someone who’s not yet on the team yet?": "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?": "Add members below to give them access to the group. Need to add someone who’s not yet a member?",
|
||||||
"Invite them to {{teamName}}": "Invite them to {{teamName}}",
|
"Invite them to {{teamName}}": "Invite them to {{teamName}}",
|
||||||
"{{userName}} was removed from the group": "{{userName}} was removed from the group",
|
"{{userName}} was removed from the group": "{{userName}} was removed from the group",
|
||||||
"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.": "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.",
|
"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.": "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.",
|
||||||
"Listing team members in the <em>{{groupName}}</em> group.": "Listing team members in the <em>{{groupName}}</em> group.",
|
"Listing members of the <em>{{groupName}}</em> group.": "Listing members of the <em>{{groupName}}</em> group.",
|
||||||
"This group has no members.": "This group has no members.",
|
"This group has no members.": "This group has no members.",
|
||||||
"Add people to {{groupName}}": "Add people to {{groupName}}",
|
"Add people to {{groupName}}": "Add people to {{groupName}}",
|
||||||
"Groups are for organizing your team. They work best when centered around a function or a responsibility — Support or Engineering for example.": "Groups are for organizing your team. They work best when centered around a function or a responsibility — Support or Engineering for example.",
|
"Groups are for organizing your team. They work best when centered around a function or a responsibility — Support or Engineering for example.": "Groups are for organizing your team. They work best when centered around a function or a responsibility — Support or Engineering for example.",
|
||||||
@@ -505,8 +508,8 @@
|
|||||||
"We sent out your invites!": "We sent out your invites!",
|
"We sent out your invites!": "We sent out your invites!",
|
||||||
"Those email addresses are already invited": "Those email addresses are already invited",
|
"Those email addresses are already invited": "Those email addresses are already invited",
|
||||||
"Sorry, you can only send {{MAX_INVITES}} invites at a time": "Sorry, you can only send {{MAX_INVITES}} invites at a time",
|
"Sorry, you can only send {{MAX_INVITES}} invites at a time": "Sorry, you can only send {{MAX_INVITES}} invites at a time",
|
||||||
"Invite team members or guests to join your knowledge base. Team members can sign in with {{signinMethods}} or use their email address.": "Invite team members or guests to join your knowledge base. Team members can sign in with {{signinMethods}} or use their email address.",
|
"Invite members or guests to join your workspace. They can sign in with {{signinMethods}} or use their email address.": "Invite members or guests to join your workspace. They can sign in with {{signinMethods}} or use their email address.",
|
||||||
"Invite team members to join your knowledge base. They will need to sign in with {{signinMethods}}.": "Invite team members to join your knowledge base. They will need to sign in with {{signinMethods}}.",
|
"Invite members to join your workspace. They will need to sign in with {{signinMethods}}.": "Invite members to join your workspace. They will need to sign in with {{signinMethods}}.",
|
||||||
"As an admin you can also <2>enable email sign-in</2>.": "As an admin you can also <2>enable email sign-in</2>.",
|
"As an admin you can also <2>enable email sign-in</2>.": "As an admin you can also <2>enable email sign-in</2>.",
|
||||||
"Want a link to share directly with your team?": "Want a link to share directly with your team?",
|
"Want a link to share directly with your team?": "Want a link to share directly with your team?",
|
||||||
"Email": "Email",
|
"Email": "Email",
|
||||||
@@ -556,7 +559,7 @@
|
|||||||
"A magic sign-in link has been sent to the email <em>{{ emailLinkSentTo }}</em> if an account exists.": "A magic sign-in link has been sent to the email <em>{{ emailLinkSentTo }}</em> if an account exists.",
|
"A magic sign-in link has been sent to the email <em>{{ emailLinkSentTo }}</em> if an account exists.": "A magic sign-in link has been sent to the email <em>{{ emailLinkSentTo }}</em> if an account exists.",
|
||||||
"Back to login": "Back to login",
|
"Back to login": "Back to login",
|
||||||
"Create an account": "Create an account",
|
"Create an account": "Create an account",
|
||||||
"Get started by choosing a sign-in method for your new team below…": "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…": "Get started by choosing a sign-in method for your new workspace below…",
|
||||||
"Login to {{ authProviderName }}": "Login to {{ authProviderName }}",
|
"Login to {{ authProviderName }}": "Login to {{ authProviderName }}",
|
||||||
"You signed in with {{ authProviderName }} last time.": "You signed in with {{ authProviderName }} last time.",
|
"You signed in with {{ authProviderName }} last time.": "You signed in with {{ authProviderName }} last time.",
|
||||||
"Or": "Or",
|
"Or": "Or",
|
||||||
@@ -640,12 +643,12 @@
|
|||||||
"Unable to upload new logo": "Unable to upload new logo",
|
"Unable to upload new logo": "Unable to upload new logo",
|
||||||
"These settings affect the way that your knowledge base appears to everyone on the team.": "These settings affect the way that your knowledge base appears to everyone on the team.",
|
"These settings affect the way that your knowledge base appears to everyone on the team.": "These settings affect the way that your knowledge base appears to everyone on the team.",
|
||||||
"The logo is displayed at the top left of the application.": "The logo is displayed at the top left of the application.",
|
"The logo is displayed at the top left of the application.": "The logo is displayed at the top left of the application.",
|
||||||
"The team name, usually the same as your company name.": "The team name, usually the same as your company name.",
|
"The workspace name, usually the same as your company name.": "The workspace name, usually the same as your company name.",
|
||||||
"Subdomain": "Subdomain",
|
"Subdomain": "Subdomain",
|
||||||
"Your knowledge base will be accessible at": "Your knowledge base will be accessible at",
|
"Your knowledge base will be accessible at": "Your knowledge base will be accessible at",
|
||||||
"Choose a subdomain to enable a login page just for your team.": "Choose a subdomain to enable a login page just for your team.",
|
"Choose a subdomain to enable a login page just for your team.": "Choose a subdomain to enable a login page just for your team.",
|
||||||
"Start view": "Start view",
|
"Start view": "Start view",
|
||||||
"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.",
|
"This is the screen that workspace members will first see when they sign in.": "This is the screen that workspace members will first see when they sign in.",
|
||||||
"Export in progress…": "Export in progress…",
|
"Export in progress…": "Export in progress…",
|
||||||
"Export deleted": "Export deleted",
|
"Export deleted": "Export deleted",
|
||||||
"A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started – if you have notifications enabled, we will email a link to <em>{{ userEmail }}</em> when it’s complete.": "A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started – if you have notifications enabled, we will email a link to <em>{{ userEmail }}</em> when it’s complete.",
|
"A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started – if you have notifications enabled, we will email a link to <em>{{ userEmail }}</em> when it’s complete.": "A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started – if you have notifications enabled, we will email a link to <em>{{ userEmail }}</em> when it’s complete.",
|
||||||
@@ -653,7 +656,7 @@
|
|||||||
"Requesting Export": "Requesting Export",
|
"Requesting Export": "Requesting Export",
|
||||||
"Export Data": "Export Data",
|
"Export Data": "Export Data",
|
||||||
"Recent exports": "Recent exports",
|
"Recent exports": "Recent exports",
|
||||||
"Manage optional and beta features. Changing these settings will affect the experience for all team members.": "Manage optional and beta features. Changing these settings will affect the experience for all team members.",
|
"Manage optional and beta features. Changing these settings will affect the experience for all members of the workspace.": "Manage optional and beta features. Changing these settings will affect the experience for all members of the workspace.",
|
||||||
"Seamless editing": "Seamless editing",
|
"Seamless editing": "Seamless editing",
|
||||||
"When enabled documents are always editable for team members that have permission. When disabled there is a separate editing view.": "When enabled documents are always editable for team members that have permission. When disabled there is a separate editing view.",
|
"When enabled documents are always editable for team members that have permission. When disabled there is a separate editing view.": "When enabled documents are always editable for team members that have permission. When disabled there is a separate editing view.",
|
||||||
"New group": "New group",
|
"New group": "New group",
|
||||||
@@ -709,7 +712,7 @@
|
|||||||
"Allow email authentication": "Allow email authentication",
|
"Allow email authentication": "Allow email authentication",
|
||||||
"When enabled, users can sign-in using their email address": "When enabled, users can sign-in using their email address",
|
"When enabled, users can sign-in using their email address": "When enabled, users can sign-in using their email address",
|
||||||
"The server must have SMTP configured to enable this setting": "The server must have SMTP configured to enable this setting",
|
"The server must have SMTP configured to enable this setting": "The server must have SMTP configured to enable this setting",
|
||||||
"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 team member",
|
"When enabled, documents can be shared publicly on the internet by any member of the workspace": "When enabled, documents can be shared publicly on the internet by any member of the workspace",
|
||||||
"Rich service embeds": "Rich service embeds",
|
"Rich service embeds": "Rich service embeds",
|
||||||
"Links to supported services are shown as rich embeds within your documents": "Links to supported services are shown as rich embeds within your documents",
|
"Links to supported services are shown as rich embeds within your documents": "Links to supported services are shown as rich embeds within your documents",
|
||||||
"Collection creation": "Collection creation",
|
"Collection creation": "Collection creation",
|
||||||
@@ -743,6 +746,9 @@
|
|||||||
"Create a webhook": "Create a webhook",
|
"Create a webhook": "Create a webhook",
|
||||||
"Zapier is a platform that allows Outline to easily integrate with thousands of other business tools. Head over to Zapier to setup a \"Zap\" and start programmatically interacting with Outline.'": "Zapier is a platform that allows Outline to easily integrate with thousands of other business tools. Head over to Zapier to setup a \"Zap\" and start programmatically interacting with Outline.'",
|
"Zapier is a platform that allows Outline to easily integrate with thousands of other business tools. Head over to Zapier to setup a \"Zap\" and start programmatically interacting with Outline.'": "Zapier is a platform that allows Outline to easily integrate with thousands of other business tools. Head over to Zapier to setup a \"Zap\" and start programmatically interacting with Outline.'",
|
||||||
"Open Zapier": "Open Zapier",
|
"Open Zapier": "Open Zapier",
|
||||||
|
"Your are creating a new workspace using your current account — <em>{{email}}</em>": "Your are creating a new workspace using your current account — <em>{{email}}</em>",
|
||||||
|
"Workspace name": "Workspace name",
|
||||||
|
"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.": "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.",
|
||||||
"Alphabetical": "Alphabetical",
|
"Alphabetical": "Alphabetical",
|
||||||
"There are no templates just yet.": "There are no templates just yet.",
|
"There are no templates just yet.": "There are no templates just yet.",
|
||||||
"You can create templates to help your team create consistent and accurate documentation.": "You can create templates to help your team create consistent and accurate documentation.",
|
"You can create templates to help your team create consistent and accurate documentation.": "You can create templates to help your team create consistent and accurate documentation.",
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import slugify from "slugify"; // Slugify, escape, and remove periods from headings so that they are
|
|
||||||
// compatible with url hashes AND dom selectors
|
|
||||||
|
|
||||||
export default function safeSlugify(text: string) {
|
|
||||||
return `h-${escape(
|
|
||||||
slugify(text, {
|
|
||||||
lower: true,
|
|
||||||
}).replace(".", "-")
|
|
||||||
)}`;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user