feat: allow ad-hoc creation of new teams (#3964)
Co-authored-by: Tom Moor <tom@getoutline.com>
This commit is contained in:
@@ -1,40 +1,64 @@
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import TeamNew from "~/scenes/TeamNew";
|
||||
import { createAction } from "~/actions";
|
||||
import { loadSessionsFromCookie } from "~/hooks/useSessions";
|
||||
import { TeamSection } from "../sections";
|
||||
|
||||
export const changeTeam = createAction({
|
||||
name: ({ t }) => t("Switch team"),
|
||||
placeholder: ({ t }) => t("Select a team"),
|
||||
keywords: "change workspace organization",
|
||||
section: "Account",
|
||||
visible: ({ currentTeamId }) => {
|
||||
const sessions = loadSessionsFromCookie();
|
||||
const otherSessions = sessions.filter(
|
||||
(session) => session.teamId !== currentTeamId
|
||||
);
|
||||
return otherSessions.length > 0;
|
||||
export const switchTeamList = getSessions().map((session) => {
|
||||
return createAction({
|
||||
name: session.name,
|
||||
section: TeamSection,
|
||||
keywords: "change switch workspace organization team",
|
||||
icon: () => <Logo alt={session.name} src={session.logoUrl} />,
|
||||
visible: ({ currentTeamId }) => currentTeamId !== session.teamId,
|
||||
perform: () => (window.location.href = session.url),
|
||||
});
|
||||
});
|
||||
|
||||
const switchTeam = createAction({
|
||||
name: ({ t }) => t("Switch workspace"),
|
||||
placeholder: ({ t }) => t("Select a workspace"),
|
||||
keywords: "change switch workspace organization team",
|
||||
section: TeamSection,
|
||||
visible: ({ currentTeamId }) =>
|
||||
getSessions({ exclude: currentTeamId }).length > 0,
|
||||
children: switchTeamList,
|
||||
});
|
||||
|
||||
export const createTeam = createAction({
|
||||
name: ({ t }) => `${t("New workspace")}…`,
|
||||
keywords: "create change switch workspace organization team",
|
||||
section: TeamSection,
|
||||
icon: <PlusIcon />,
|
||||
visible: ({ stores, currentTeamId }) => {
|
||||
return stores.policies.abilities(currentTeamId ?? "").createTeam;
|
||||
},
|
||||
children: ({ currentTeamId }) => {
|
||||
const sessions = loadSessionsFromCookie();
|
||||
const otherSessions = sessions.filter(
|
||||
(session) => session.teamId !== currentTeamId
|
||||
);
|
||||
|
||||
return otherSessions.map((session) => ({
|
||||
id: session.url,
|
||||
name: session.name,
|
||||
section: "Account",
|
||||
icon: <Logo alt={session.name} src={session.logoUrl} />,
|
||||
perform: () => (window.location.href = session.url),
|
||||
}));
|
||||
perform: ({ t, event, stores }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
const { user } = stores.auth;
|
||||
user &&
|
||||
stores.dialogs.openModal({
|
||||
title: t("Create a workspace"),
|
||||
content: <TeamNew user={user} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function getSessions(params?: { exclude?: string }) {
|
||||
const sessions = loadSessionsFromCookie();
|
||||
const otherSessions = sessions.filter(
|
||||
(session) => session.teamId !== params?.exclude
|
||||
);
|
||||
return otherSessions;
|
||||
}
|
||||
|
||||
const Logo = styled("img")`
|
||||
border-radius: 2px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
export const rootTeamActions = [changeTeam];
|
||||
export const rootTeamActions = [switchTeam, createTeam];
|
||||
|
||||
@@ -8,7 +8,7 @@ import { UserSection } from "~/actions/sections";
|
||||
export const inviteUser = createAction({
|
||||
name: ({ t }) => `${t("Invite people")}…`,
|
||||
icon: <PlusIcon />,
|
||||
keywords: "team member user",
|
||||
keywords: "team member workspace user",
|
||||
section: UserSection,
|
||||
visible: ({ stores }) =>
|
||||
stores.policies.abilities(stores.auth.team?.id || "").inviteUser,
|
||||
|
||||
@@ -14,5 +14,7 @@ export const NavigationSection = ({ t }: ActionContext) => t("Navigation");
|
||||
|
||||
export const UserSection = ({ t }: ActionContext) => t("People");
|
||||
|
||||
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
|
||||
|
||||
export const RecentSearchesSection = ({ t }: ActionContext) =>
|
||||
t("Recent searches");
|
||||
|
||||
@@ -5,7 +5,7 @@ import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { navigateToSettings, logout } from "~/actions/definitions/navigation";
|
||||
import { changeTeam } from "~/actions/definitions/teams";
|
||||
import { createTeam, switchTeamList } from "~/actions/definitions/teams";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useSessions from "~/hooks/useSessions";
|
||||
@@ -34,7 +34,13 @@ const OrganizationMenu: React.FC = ({ children }) => {
|
||||
// NOTE: it's useful to memoize on the team id and session because the action
|
||||
// menu is not cached at all.
|
||||
const actions = React.useMemo(() => {
|
||||
return [navigateToSettings, separator(), changeTeam, logout];
|
||||
return [
|
||||
...switchTeamList,
|
||||
createTeam,
|
||||
separator(),
|
||||
navigateToSettings,
|
||||
logout,
|
||||
];
|
||||
}, [team.id, sessions]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -213,7 +213,7 @@ function CollectionPermissions({ collectionId }: Props) {
|
||||
<PermissionExplainer size="small">
|
||||
{!collection.permission && (
|
||||
<Trans
|
||||
defaults="The <em>{{ collectionName }}</em> collection is private. Team members have no access to it by default."
|
||||
defaults="The <em>{{ collectionName }}</em> collection is private. Workspace members have no access to it by default."
|
||||
values={{
|
||||
collectionName,
|
||||
}}
|
||||
@@ -224,8 +224,7 @@ function CollectionPermissions({ collectionId }: Props) {
|
||||
)}
|
||||
{collection.permission === CollectionPermission.ReadWrite && (
|
||||
<Trans
|
||||
defaults="Team members can view and edit documents in the <em>{{ collectionName }}</em> collection by
|
||||
default."
|
||||
defaults="Workspace members can view and edit documents in the <em>{{ collectionName }}</em> collection by default."
|
||||
values={{
|
||||
collectionName,
|
||||
}}
|
||||
@@ -236,7 +235,8 @@ function CollectionPermissions({ collectionId }: Props) {
|
||||
)}
|
||||
{collection.permission === CollectionPermission.Read && (
|
||||
<Trans
|
||||
defaults="Team members can view documents in the <em>{{ collectionName }}</em> collection by default."
|
||||
defaults="Workspace members can view documents in the <em>{{ collectionName }}</em> collection by
|
||||
default."
|
||||
values={{
|
||||
collectionName,
|
||||
}}
|
||||
@@ -288,9 +288,7 @@ function CollectionPermissions({ collectionId }: Props) {
|
||||
<Divider />
|
||||
{isEmpty && (
|
||||
<Empty>
|
||||
<Trans>
|
||||
Add specific access for individual groups and team members
|
||||
</Trans>
|
||||
<Trans>Add additional access for individual members and groups</Trans>
|
||||
</Empty>
|
||||
)}
|
||||
<PaginatedList
|
||||
|
||||
@@ -165,7 +165,7 @@ function SharePopover({
|
||||
<SwitchText>
|
||||
{share?.published
|
||||
? t("Anyone with the link can view this document")
|
||||
: t("Only team members with permission can view")}
|
||||
: t("Only members with permission can view")}
|
||||
{share?.lastAccessedAt && (
|
||||
<>
|
||||
.{" "}
|
||||
@@ -185,7 +185,7 @@ function SharePopover({
|
||||
</SwitchWrapper>
|
||||
) : (
|
||||
<Text type="secondary">
|
||||
{t("Only team members with permission can view")}
|
||||
{t("Only members with permission can view")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ function DocumentReparent({ collection, item, onSubmit, onCancel }: Props) {
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Text type="secondary">
|
||||
<Trans
|
||||
defaults="Heads up – moving the document <em>{{ title }}</em> to the <em>{{ newCollectionName }}</em> collection will grant all team members <em>{{ newPermission }}</em>, they currently have {{ prevPermission }}."
|
||||
defaults="Heads up – moving the document <em>{{ title }}</em> to the <em>{{ newCollectionName }}</em> collection will grant all members of the workspace <em>{{ newPermission }}</em>, they currently have {{ prevPermission }}."
|
||||
values={{
|
||||
title: item.title,
|
||||
prevCollectionName: prevCollection?.name,
|
||||
|
||||
@@ -84,7 +84,7 @@ class AddPeopleToGroup extends React.Component<Props> {
|
||||
<Flex column>
|
||||
<Text type="secondary">
|
||||
{t(
|
||||
"Add team members below to give them access to the group. Need to add someone who’s not yet on the team yet?"
|
||||
"Add members below to give them access to the group. Need to add someone who’s not yet a member?"
|
||||
)}{" "}
|
||||
<ButtonLink onClick={this.handleInviteModalOpen}>
|
||||
{t("Invite them to {{teamName}}", {
|
||||
|
||||
@@ -59,7 +59,7 @@ function GroupMembers({ group }: Props) {
|
||||
<>
|
||||
<Text type="secondary">
|
||||
<Trans
|
||||
defaults="Add and remove team members in the <em>{{groupName}}</em> group. Adding people to the group will give them access to any collections this group has been added to."
|
||||
defaults="Add and remove members to the <em>{{groupName}}</em> group. Members of the group will have access to any collections this group has been added to."
|
||||
values={{
|
||||
groupName: group.name,
|
||||
}}
|
||||
@@ -82,7 +82,7 @@ function GroupMembers({ group }: Props) {
|
||||
) : (
|
||||
<Text type="secondary">
|
||||
<Trans
|
||||
defaults="Listing team members in the <em>{{groupName}}</em> group."
|
||||
defaults="Listing members of the <em>{{groupName}}</em> group."
|
||||
values={{
|
||||
groupName: group.name,
|
||||
}}
|
||||
|
||||
@@ -150,7 +150,7 @@ function Invite({ onSubmit }: Props) {
|
||||
{team.guestSignin ? (
|
||||
<Text type="secondary">
|
||||
<Trans
|
||||
defaults="Invite team members or guests to join your knowledge base. Team members can sign in with {{signinMethods}} or use their email address."
|
||||
defaults="Invite members or guests to join your workspace. They can sign in with {{signinMethods}} or use their email address."
|
||||
values={{
|
||||
signinMethods: team.signinMethods,
|
||||
}}
|
||||
@@ -159,7 +159,7 @@ function Invite({ onSubmit }: Props) {
|
||||
) : (
|
||||
<Text type="secondary">
|
||||
<Trans
|
||||
defaults="Invite team members to join your knowledge base. They will need to sign in with {{signinMethods}}."
|
||||
defaults="Invite members to join your workspace. They will need to sign in with {{signinMethods}}."
|
||||
values={{
|
||||
signinMethods: team.signinMethods,
|
||||
}}
|
||||
|
||||
@@ -185,7 +185,7 @@ function Login({ children }: Props) {
|
||||
<StyledHeading centered>{t("Create an account")}</StyledHeading>
|
||||
<GetStarted>
|
||||
{t(
|
||||
"Get started by choosing a sign-in method for your new team below…"
|
||||
"Get started by choosing a sign-in method for your new workspace below…"
|
||||
)}
|
||||
</GetStarted>
|
||||
</>
|
||||
|
||||
@@ -123,7 +123,7 @@ function Details() {
|
||||
label={t("Name")}
|
||||
name="name"
|
||||
description={t(
|
||||
"The team name, usually the same as your company name."
|
||||
"The workspace name, usually the same as your company name."
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
@@ -165,7 +165,7 @@ function Details() {
|
||||
label={t("Start view")}
|
||||
name="defaultCollectionId"
|
||||
description={t(
|
||||
"This is the screen that team members will first see when they sign in."
|
||||
"This is the screen that workspace members will first see when they sign in."
|
||||
)}
|
||||
>
|
||||
<DefaultCollectionInputSelect
|
||||
|
||||
@@ -38,7 +38,7 @@ function Features() {
|
||||
<Text type="secondary">
|
||||
<Trans>
|
||||
Manage optional and beta features. Changing these settings will affect
|
||||
the experience for all team members.
|
||||
the experience for all members of the workspace.
|
||||
</Trans>
|
||||
</Text>
|
||||
{team.collaborativeEditing && (
|
||||
|
||||
@@ -207,7 +207,7 @@ function Security() {
|
||||
label={t("Public document sharing")}
|
||||
name="sharing"
|
||||
description={t(
|
||||
"When enabled, documents can be shared publicly on the internet by any team member"
|
||||
"When enabled, documents can be shared publicly on the internet by any member of the workspace"
|
||||
)}
|
||||
>
|
||||
<Switch id="sharing" checked={data.sharing} onChange={handleChange} />
|
||||
|
||||
88
app/scenes/TeamNew.tsx
Normal file
88
app/scenes/TeamNew.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import User from "~/models/User";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input from "~/components/Input";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
};
|
||||
|
||||
function TeamNew({ user }: Props) {
|
||||
const { auth } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToasts();
|
||||
const [name, setName] = React.useState("");
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
|
||||
const handleSubmit = async (ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
if (name.trim().length > 1) {
|
||||
await auth.createTeam({
|
||||
name: name.trim(),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setName(ev.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Text type="secondary">
|
||||
<Trans
|
||||
defaults="Your are creating a new workspace using your current account — <em>{{email}}</em>"
|
||||
values={{
|
||||
email: user.email,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<Flex>
|
||||
<Input
|
||||
type="text"
|
||||
label={t("Workspace name")}
|
||||
onChange={handleNameChange}
|
||||
value={name}
|
||||
required
|
||||
autoFocus
|
||||
flex
|
||||
/>
|
||||
</Flex>
|
||||
<Text type="secondary">
|
||||
<Trans>
|
||||
When your new workspace is created, you will be the admin, meaning
|
||||
you will have the highest level of permissions and the ability to
|
||||
invite others.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Button type="submit" disabled={isSaving || !(name.trim().length > 1)}>
|
||||
{isSaving ? `${t("Creating")}…` : t("Create")}
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(TeamNew);
|
||||
@@ -263,6 +263,20 @@ export default class AuthStore {
|
||||
}
|
||||
};
|
||||
|
||||
@action
|
||||
createTeam = async (params: { name: string }) => {
|
||||
this.isSaving = true;
|
||||
|
||||
try {
|
||||
const res = await client.post(`/teams.create`, params);
|
||||
invariant(res?.success, "Unable to create team");
|
||||
|
||||
window.location.href = res.data.transferUrl;
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
};
|
||||
|
||||
@action
|
||||
logout = async (savePath = false) => {
|
||||
// if this logout was forced from an authenticated route then
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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({
|
||||
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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
"I’m sure – Delete": "I’m 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 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",
|
||||
"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 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}}",
|
||||
"{{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 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",
|
||||
"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.",
|
||||
|
||||
@@ -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