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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -25,7 +25,7 @@ describe("accountProvisioner", () => {
username: "jtester",
},
team: {
name: "New team",
name: "New workspace",
avatarUrl: "https://example.com/avatar.png",
subdomain: "example",
},
@@ -44,7 +44,7 @@ describe("accountProvisioner", () => {
expect(auth.accessToken).toEqual("123");
expect(auth.scopes.length).toEqual(1);
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.username).toEqual("jtester");
expect(isNewUser).toEqual(true);

View File

@@ -1,10 +1,8 @@
import invariant from "invariant";
import { UniqueConstraintError } from "sequelize";
import WelcomeEmail from "@server/emails/templates/WelcomeEmail";
import {
AuthenticationError,
InvalidAuthenticationError,
EmailAuthenticationRequiredError,
AuthenticationProviderDisabledError,
} from "@server/errors";
import { APM } from "@server/logging/tracing";
@@ -181,25 +179,7 @@ async function accountProvisioner({
isNewTeam,
};
} catch (err) {
if (err instanceof UniqueConstraintError) {
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;
throw AuthenticationError(err.message);
}
}

View 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);

View File

@@ -35,6 +35,7 @@ describe("teamProvisioner", () => {
await buildTeam({
subdomain: "myteam",
});
const result = await teamProvisioner({
name: "Test team",
subdomain: "myteam",
@@ -46,6 +47,7 @@ describe("teamProvisioner", () => {
ip,
});
expect(result.isNewTeam).toEqual(true);
expect(result.team.subdomain).toEqual("myteam1");
});

View File

@@ -1,3 +1,4 @@
import teamCreator from "@server/commands/teamCreator";
import { sequelize } from "@server/database/sequelize";
import env from "@server/env";
import {
@@ -5,10 +6,8 @@ import {
InvalidAuthenticationError,
MaximumTeamsError,
} from "@server/errors";
import Logger from "@server/logging/Logger";
import { APM } from "@server/logging/tracing";
import { Team, AuthenticationProvider, Event } from "@server/models";
import { generateAvatarUrl } from "@server/utils/avatars";
import { Team, AuthenticationProvider } from "@server/models";
type TeamProvisionerResult = {
team: Team;
@@ -106,54 +105,18 @@ async function teamProvisioner({
}
}
// 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({
// We cannot find an existing team, so we create a new one
const team = await sequelize.transaction((transaction) => {
return teamCreator({
name,
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,
avatarUrl,
authenticationProviders: [authenticationProvider],
ip,
transaction,
});
}
});
return {
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({
serviceName: "command",
spanName: "teamProvisioner",

View File

@@ -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"
});
}
};

View File

@@ -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"
});
}
};

View File

@@ -55,7 +55,7 @@ export type TeamPreferences = Record<string, unknown>;
@Fix
class Team extends ParanoidModel {
@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
name: string;

View File

@@ -1,3 +1,4 @@
import env from "@server/env";
import { buildUser, buildTeam, buildAdmin } from "@server/test/factories";
import { setupTestDatabase } from "@server/test/support";
import { serialize } from "./index";
@@ -12,6 +13,7 @@ it("should allow reading only", async () => {
const abilities = serialize(user, team);
expect(abilities.read).toEqual(true);
expect(abilities.manage).toEqual(false);
expect(abilities.createTeam).toEqual(false);
expect(abilities.createAttachment).toEqual(true);
expect(abilities.createCollection).toEqual(true);
expect(abilities.createDocument).toEqual(true);
@@ -27,6 +29,25 @@ it("should allow admins to manage", async () => {
const abilities = serialize(admin, team);
expect(abilities.read).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.createCollection).toEqual(true);
expect(abilities.createDocument).toEqual(true);

View File

@@ -1,3 +1,4 @@
import env from "@server/env";
import { Team, User } from "@server/models";
import { allow } from "./cancan";
@@ -10,6 +11,12 @@ allow(User, "share", Team, (user, team) => {
return team.sharing;
});
allow(User, "createTeam", Team, () => {
if (env.DEPLOYMENT !== "hosted") {
throw "createTeam only available on cloud";
}
});
allow(User, ["update", "manage"], Team, (user, team) => {
if (!team || user.isViewer || user.teamId !== team.id) {
return false;

View File

@@ -73,21 +73,20 @@ describe("#authenticationProviders.update", () => {
const user = await buildAdmin({
teamId: team.id,
});
await team.$create("authenticationProvider", {
const googleProvider = await team.$create("authenticationProvider", {
name: "google",
providerId: uuidv4(),
});
const authenticationProviders = await team.$get("authenticationProviders");
const res = await server.post("/api/authenticationProviders.update", {
body: {
id: authenticationProviders[0].id,
id: googleProvider.id,
isEnabled: false,
token: user.getJwtToken(),
},
});
const body = await res.json();
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.isConnected).toBe(true);
});

View File

@@ -30,6 +30,7 @@ router.post("authenticationProviders.update", auth(), async (ctx) => {
assertPresent(isEnabled, "isEnabled is required");
const { user } = ctx.state;
const authenticationProvider = await AuthenticationProvider.findByPk(id);
authorize(user, "update", authenticationProvider);
const enabled = !!isEnabled;

View File

@@ -1,9 +1,41 @@
import env from "@server/env";
import { TeamDomain } from "@server/models";
import { buildAdmin, buildCollection, buildTeam } from "@server/test/factories";
import { seed, getTestServer } from "@server/test/support";
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", () => {
it("should update team details", async () => {
const { admin } = await seed();

View File

@@ -1,9 +1,12 @@
import invariant from "invariant";
import Router from "koa-router";
import { RateLimiterStrategy } from "@server/RateLimiter";
import teamCreator from "@server/commands/teamCreator";
import teamUpdater from "@server/commands/teamUpdater";
import { sequelize } from "@server/database/sequelize";
import auth from "@server/middlewares/authentication";
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 { presentTeam, presentPolicies } from "@server/presenters";
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;

View File

@@ -228,7 +228,6 @@ export default class ListItem extends Node {
!$pos.nodeBefore ||
!["list_item", "checkbox_item"].includes($pos.nodeBefore.type.name)
) {
console.log("Node before not a list item");
return false;
}
@@ -259,7 +258,6 @@ export default class ListItem extends Node {
!$pos.nodeAfter ||
!["list_item", "checkbox_item"].includes($pos.nodeAfter.type.name)
) {
console.log("Node after not a list item");
return false;
}

View File

@@ -67,8 +67,10 @@
"Appearance": "Appearance",
"Change theme": "Change theme",
"Change theme to": "Change theme to",
"Switch team": "Switch team",
"Select a team": "Select a team",
"Switch workspace": "Switch workspace",
"Select a workspace": "Select a workspace",
"New workspace": "New workspace",
"Create a workspace": "Create a workspace",
"Invite people": "Invite people",
"Collection": "Collection",
"Debug": "Debug",
@@ -76,6 +78,7 @@
"Revision": "Revision",
"Navigation": "Navigation",
"People": "People",
"Workspace": "Workspace",
"Recent searches": "Recent searches",
"currently editing": "currently editing",
"currently viewing": "currently viewing",
@@ -84,7 +87,7 @@
"Viewers": "Viewers",
"Im sure Delete": "Im sure Delete",
"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.",
"Sorry, an error occurred saving the collection": "Sorry, an error occurred saving the collection",
"Add a description": "Add a description",
@@ -122,15 +125,15 @@
"Viewed": "Viewed",
"in": "in",
"nested document": "nested document",
"nested document_plural": "nested documents",
"nested document_plural": "nested document",
"Viewed by": "Viewed by",
"only you": "only you",
"person": "person",
"people": "people",
"{{ total }} task": "{{ total }} task",
"{{ total }} task_plural": "{{ total }} tasks",
"{{ total }} task_plural": "{{ total }} task",
"{{ 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",
"Template created, go ahead and customize it": "Template created, go ahead and customize it",
"Creating": "Creating",
@@ -193,9 +196,9 @@
"Show more": "Show more",
"Toggle sidebar": "Toggle sidebar",
"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",
"Return to App": "Back to App",
"Return to App": "Return to App",
"Installation": "Installation",
"No results": "No results",
"Previous page": "Previous page",
@@ -223,7 +226,7 @@
"Align left": "Align left",
"Align right": "Align right",
"Bulleted list": "Bulleted list",
"Todo list": "Task list",
"Todo list": "Todo list",
"Code block": "Code block",
"Copied to clipboard": "Copied to clipboard",
"Code": "Code",
@@ -289,7 +292,7 @@
"Group member options": "Group member options",
"Remove": "Remove",
"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",
"Alphabetical sort": "Alphabetical sort",
"Manual sort": "Manual sort",
@@ -401,15 +404,15 @@
"Could not update permissions": "Could not update permissions",
"Public document sharing permissions were updated": "Public document sharing permissions were updated",
"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.",
"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.",
"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.",
"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.",
"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.",
"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.",
"Public sharing is currently disabled in the team security settings.": "Public sharing is currently disabled in the team security settings.",
"Additional access": "Additional access",
"Add groups": "Add groups",
"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 people to {{ collectionName }}": "Add people to {{ collectionName }}",
"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",
"Publish to internet": "Publish to internet",
"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 }}.",
"Share nested documents": "Share nested documents",
"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.",
"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 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>._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>.": "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 document</em>.",
"If youd like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.": "If youd like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.",
"Archiving": "Archiving",
"Document moved": "Document moved",
@@ -470,7 +473,7 @@
"view and edit access": "view and edit access",
"view only access": "view only 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",
"Cancel": "Cancel",
"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.",
"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",
"Add team members below to give them access to the group. Need to add someone whos not yet on the team yet?": "Add team members below to give them access to the group. Need to add someone whos not yet on the team yet?",
"Add members below to give them access to the group. Need to add someone whos not yet a member?": "Add members below to give them access to the group. Need to add someone whos not yet a member?",
"Invite them to {{teamName}}": "Invite them to {{teamName}}",
"{{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.",
"Listing team members in the <em>{{groupName}}</em> group.": "Listing team members in the <em>{{groupName}}</em> group.",
"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 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.",
"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.",
@@ -505,8 +508,8 @@
"We sent out your invites!": "We sent out your invites!",
"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",
"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 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 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 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>.",
"Want a link to share directly with your team?": "Want a link to share directly with your team?",
"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.",
"Back to login": "Back to login",
"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 }}",
"You signed in with {{ authProviderName }} last time.": "You signed in with {{ authProviderName }} last time.",
"Or": "Or",
@@ -640,12 +643,12 @@
"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.",
"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",
"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.",
"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 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 its 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 its complete.",
@@ -653,7 +656,7 @@
"Requesting Export": "Requesting Export",
"Export Data": "Export Data",
"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",
"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",
@@ -709,7 +712,7 @@
"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",
"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",
"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",
@@ -743,6 +746,9 @@
"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.'",
"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",
"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.",

View File

@@ -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(".", "-")
)}`;
}