From 233f3af667bdc16a6c7d23f86144713c9c316c2a Mon Sep 17 00:00:00 2001 From: Nan Yu Date: Tue, 19 Apr 2022 12:27:23 -0700 Subject: [PATCH] feat: allow admins to require invites before user accounts can be created (#3381) * allow admins to require invites before user accounts can be created * use new dialog component for general confirmation dialogs --- app/actions/definitions/documents.tsx | 11 +- app/components/Button.tsx | 2 +- app/components/ConfirmationDialog.tsx | 58 ++++++++++ app/components/DocumentTemplatizeDialog.tsx | 52 +++++++++ app/components/Modal.tsx | 13 ++- app/menus/DocumentMenu.tsx | 29 +---- app/models/Team.ts | 4 + app/scenes/DocumentTemplatize.tsx | 78 ------------- app/scenes/Login/Notices.tsx | 8 ++ app/scenes/Settings/Security.tsx | 104 +++++++++++++++--- server/commands/teamUpdater.ts | 4 + server/commands/userCreator.test.ts | 31 +++++- server/commands/userCreator.ts | 10 +- server/errors.ts | 8 ++ ...20413213537-add-inviteRequired-to-teams.js | 14 +++ server/models/Team.ts | 4 + server/presenters/team.ts | 1 + server/routes/api/team.ts | 2 + shared/i18n/locales/en_US/translation.json | 16 ++- 19 files changed, 313 insertions(+), 136 deletions(-) create mode 100644 app/components/ConfirmationDialog.tsx create mode 100644 app/components/DocumentTemplatizeDialog.tsx delete mode 100644 app/scenes/DocumentTemplatize.tsx create mode 100644 server/migrations/20220413213537-add-inviteRequired-to-teams.js diff --git a/app/actions/definitions/documents.tsx b/app/actions/definitions/documents.tsx index 47a16500c..636a78f2a 100644 --- a/app/actions/definitions/documents.tsx +++ b/app/actions/definitions/documents.tsx @@ -14,7 +14,7 @@ import { } from "outline-icons"; import * as React from "react"; import getDataTransferFiles from "@shared/utils/getDataTransferFiles"; -import DocumentTemplatize from "~/scenes/DocumentTemplatize"; +import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog"; import { createAction } from "~/actions"; import { DocumentSection } from "~/actions/sections"; import history from "~/utils/history"; @@ -306,18 +306,13 @@ export const createTemplate = createAction({ if (!activeDocumentId) { return; } - event?.preventDefault(); event?.stopPropagation(); stores.dialogs.openModal({ title: t("Create template"), - content: ( - - ), + isCentered: true, + content: , }); }, }); diff --git a/app/components/Button.tsx b/app/components/Button.tsx index 8f4a22527..21c570f9c 100644 --- a/app/components/Button.tsx +++ b/app/components/Button.tsx @@ -111,7 +111,7 @@ const RealButton = styled.button<{ } &:disabled { - background: none; + background: ${lighten(0.05, props.theme.danger)}; } &.focus-visible { diff --git a/app/components/ConfirmationDialog.tsx b/app/components/ConfirmationDialog.tsx new file mode 100644 index 000000000..ac5baffa1 --- /dev/null +++ b/app/components/ConfirmationDialog.tsx @@ -0,0 +1,58 @@ +import { observer } from "mobx-react"; +import * as React from "react"; +import Button from "~/components/Button"; +import Flex from "~/components/Flex"; +import Text from "~/components/Text"; +import useStores from "~/hooks/useStores"; +import useToasts from "~/hooks/useToasts"; + +type Props = { + onSubmit: () => void; + children: JSX.Element; + submitText?: string; + savingText?: string; + danger?: boolean; +}; + +function ConfirmationDialog({ + onSubmit, + children, + submitText, + savingText, + danger, +}: Props) { + const [isSaving, setIsSaving] = React.useState(false); + const { dialogs } = useStores(); + const { showToast } = useToasts(); + + const handleSubmit = React.useCallback( + async (ev: React.SyntheticEvent) => { + ev.preventDefault(); + setIsSaving(true); + try { + await onSubmit(); + dialogs.closeAllModals(); + } catch (err) { + showToast(err.message, { + type: "error", + }); + } finally { + setIsSaving(false); + } + }, + [onSubmit, dialogs, showToast] + ); + + return ( + +
+ {children} + +
+
+ ); +} + +export default observer(ConfirmationDialog); diff --git a/app/components/DocumentTemplatizeDialog.tsx b/app/components/DocumentTemplatizeDialog.tsx new file mode 100644 index 000000000..ad643f974 --- /dev/null +++ b/app/components/DocumentTemplatizeDialog.tsx @@ -0,0 +1,52 @@ +import invariant from "invariant"; +import { observer } from "mobx-react"; +import * as React from "react"; +import { useTranslation, Trans } from "react-i18next"; +import { useHistory } from "react-router-dom"; +import ConfirmationDialog from "~/components/ConfirmationDialog"; +import useStores from "~/hooks/useStores"; +import useToasts from "~/hooks/useToasts"; +import { documentUrl } from "~/utils/routeHelpers"; + +type Props = { + documentId: string; +}; + +function DocumentTemplatizeDialog({ documentId }: Props) { + const history = useHistory(); + const { showToast } = useToasts(); + const { t } = useTranslation(); + const { documents } = useStores(); + const document = documents.get(documentId); + invariant(document, "Document must exist"); + + const handleSubmit = React.useCallback(async () => { + const template = await document?.templatize(); + if (template) { + history.push(documentUrl(template)); + showToast(t("Template created, go ahead and customize it"), { + type: "info", + }); + } + }, [document, showToast, history, t]); + + return ( + + , + }} + /> + + ); +} + +export default observer(DocumentTemplatizeDialog); diff --git a/app/components/Modal.tsx b/app/components/Modal.tsx index df4351b28..0d3c814a6 100644 --- a/app/components/Modal.tsx +++ b/app/components/Modal.tsx @@ -82,9 +82,11 @@ const Modal: React.FC = ({ {title} )} - - - + + + + + {children} @@ -261,6 +263,11 @@ const Small = styled.div` &[aria-expanded="true"] { background: ${(props) => props.theme.sidebarControlHoverBackground}; } + vertical-align: middle; + } + + ${Header} { + align-items: start; } `; diff --git a/app/menus/DocumentMenu.tsx b/app/menus/DocumentMenu.tsx index 1619d9aed..ec2070708 100644 --- a/app/menus/DocumentMenu.tsx +++ b/app/menus/DocumentMenu.tsx @@ -9,7 +9,6 @@ import { MoveIcon, HistoryIcon, UnpublishIcon, - ShapesIcon, PrintIcon, ImportIcon, NewDocumentIcon, @@ -29,7 +28,6 @@ import Document from "~/models/Document"; import DocumentDelete from "~/scenes/DocumentDelete"; import DocumentMove from "~/scenes/DocumentMove"; import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete"; -import DocumentTemplatize from "~/scenes/DocumentTemplatize"; import CollectionIcon from "~/components/CollectionIcon"; import ContextMenu from "~/components/ContextMenu"; import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton"; @@ -39,7 +37,7 @@ import Flex from "~/components/Flex"; import Modal from "~/components/Modal"; import Switch from "~/components/Switch"; import { actionToMenuItem } from "~/actions"; -import { pinDocument } from "~/actions/definitions/documents"; +import { pinDocument, createTemplate } from "~/actions/definitions/documents"; import useActionContext from "~/hooks/useActionContext"; import useCurrentTeam from "~/hooks/useCurrentTeam"; import useMobile from "~/hooks/useMobile"; @@ -103,7 +101,6 @@ function DocumentMenu({ setShowPermanentDeleteModal, ] = React.useState(false); const [showMoveModal, setShowMoveModal] = React.useState(false); - const [showTemplateModal, setShowTemplateModal] = React.useState(false); const file = React.useRef(null); const handleOpen = React.useCallback(() => { @@ -330,6 +327,7 @@ function DocumentMenu({ visible: !document.isStarred && !!can.star, icon: , }, + // Pin document actionToMenuItem(pinDocument, context), { type: "separator", @@ -357,14 +355,8 @@ function DocumentMenu({ onClick: handleImportDocument, icon: , }, - { - type: "button", - title: `${t("Create template")}…`, - onClick: () => setShowTemplateModal(true), - visible: - !!can.update && !document.isTemplate && !document.isDraft, - icon: , - }, + // Templatize document + actionToMenuItem(createTemplate, context), { type: "button", title: t("Duplicate"), @@ -518,19 +510,6 @@ function DocumentMenu({ /> )} - {can.update && ( - setShowTemplateModal(false)} - isOpen={showTemplateModal} - isCentered - > - setShowTemplateModal(false)} - /> - - )} )} diff --git a/app/models/Team.ts b/app/models/Team.ts index 516bb2e0f..e9565fde4 100644 --- a/app/models/Team.ts +++ b/app/models/Team.ts @@ -19,6 +19,10 @@ class Team extends BaseModel { @observable sharing: boolean; + @Field + @observable + inviteRequired: boolean; + @Field @observable collaborativeEditing: boolean; diff --git a/app/scenes/DocumentTemplatize.tsx b/app/scenes/DocumentTemplatize.tsx deleted file mode 100644 index 0db4135d3..000000000 --- a/app/scenes/DocumentTemplatize.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import invariant from "invariant"; -import { observer } from "mobx-react"; -import * as React from "react"; -import { useState } from "react"; -import { useTranslation, Trans } from "react-i18next"; -import { useHistory } from "react-router-dom"; -import Button from "~/components/Button"; -import Flex from "~/components/Flex"; -import Text from "~/components/Text"; -import useStores from "~/hooks/useStores"; -import useToasts from "~/hooks/useToasts"; -import { documentUrl } from "~/utils/routeHelpers"; - -type Props = { - documentId: string; - onSubmit: () => void; -}; - -function DocumentTemplatize({ documentId, onSubmit }: Props) { - const [isSaving, setIsSaving] = useState(); - const history = useHistory(); - const { showToast } = useToasts(); - const { t } = useTranslation(); - const { documents } = useStores(); - const document = documents.get(documentId); - invariant(document, "Document must exist"); - - const handleSubmit = React.useCallback( - async (ev: React.SyntheticEvent) => { - ev.preventDefault(); - setIsSaving(true); - - try { - const template = await document.templatize(); - - if (template) { - history.push(documentUrl(template)); - - showToast(t("Template created, go ahead and customize it"), { - type: "info", - }); - } - - onSubmit(); - } catch (err) { - showToast(err.message, { - type: "error", - }); - } finally { - setIsSaving(false); - } - }, - [document, showToast, history, onSubmit, t] - ); - - return ( - -
- - , - }} - /> - - -
-
- ); -} - -export default observer(DocumentTemplatize); diff --git a/app/scenes/Login/Notices.tsx b/app/scenes/Login/Notices.tsx index 91cead7cd..639e6e13d 100644 --- a/app/scenes/Login/Notices.tsx +++ b/app/scenes/Login/Notices.tsx @@ -71,6 +71,14 @@ export default function Notices() { admin. )} + {notice === "invite-required" && ( + + The team you are trying to join requires an invite before you can + create an account. +
+ Please request an invite from your team admin and try again. +
+ )} ); } diff --git a/app/scenes/Settings/Security.tsx b/app/scenes/Settings/Security.tsx index 4ef5c8b59..b17e2f385 100644 --- a/app/scenes/Settings/Security.tsx +++ b/app/scenes/Settings/Security.tsx @@ -4,6 +4,7 @@ import { PadlockIcon } from "outline-icons"; import { useState } from "react"; import * as React from "react"; import { useTranslation, Trans } from "react-i18next"; +import ConfirmationDialog from "~/components/ConfirmationDialog"; import Heading from "~/components/Heading"; import InputSelect from "~/components/InputSelect"; import Scene from "~/components/Scene"; @@ -16,7 +17,7 @@ import useToasts from "~/hooks/useToasts"; import SettingRow from "./components/SettingRow"; function Security() { - const { auth } = useStores(); + const { auth, dialogs } = useStores(); const team = useCurrentTeam(); const { t } = useTranslation(); const { showToast } = useToasts(); @@ -26,8 +27,11 @@ function Security() { guestSignin: team.guestSignin, defaultUserRole: team.defaultUserRole, memberCollectionCreate: team.memberCollectionCreate, + inviteRequired: team.inviteRequired, }); + const authenticationMethods = team.signinMethods; + const showSuccessMessage = React.useMemo( () => debounce(() => { @@ -38,22 +42,72 @@ function Security() { [showToast, t] ); - const handleChange = React.useCallback( - async (ev: React.ChangeEvent) => { - const newData = { ...data, [ev.target.id]: ev.target.checked }; - setData(newData); - await auth.updateTeam(newData); - showSuccessMessage(); + const saveData = React.useCallback( + async (newData) => { + try { + setData(newData); + await auth.updateTeam(newData); + showSuccessMessage(); + } catch (err) { + showToast(err.message, { + type: "error", + }); + } }, - [auth, data, showSuccessMessage] + [auth, showSuccessMessage, showToast] ); - const handleDefaultRoleChange = async (newDefaultRole: string) => { - const newData = { ...data, defaultUserRole: newDefaultRole }; - setData(newData); - await auth.updateTeam(newData); - showSuccessMessage(); - }; + const handleChange = React.useCallback( + async (ev: React.ChangeEvent) => { + await saveData({ ...data, [ev.target.id]: ev.target.checked }); + }, + [data, saveData] + ); + + const handleDefaultRoleChange = React.useCallback( + async (newDefaultRole: string) => { + await saveData({ ...data, defaultUserRole: newDefaultRole }); + }, + [data, saveData] + ); + + const handleAllowSignupsChange = React.useCallback( + async (ev: React.ChangeEvent) => { + const inviteRequired = !ev.target.checked; + const newData = { ...data, inviteRequired }; + + if (inviteRequired) { + dialogs.openModal({ + isCentered: true, + title: t("Are you sure you want to disable authorized signups?"), + content: ( + { + await saveData(newData); + }} + submitText={t("I’m sure — Disable")} + savingText={`${t("Disabling")}…`} + danger + > + , + }} + /> + + ), + }); + return; + } + + await saveData(newData); + }, + [data, saveData, t, dialogs, authenticationMethods] + ); return ( }> @@ -117,6 +171,28 @@ function Security() { /> + , + }} + /> + } + > + + + { collaborativeEditing, defaultCollectionId, defaultUserRole, + inviteRequired, } = params; if (subdomain !== undefined && process.env.SUBDOMAINS_ENABLED === "true") { @@ -54,6 +55,9 @@ const teamUpdater = async ({ params, user, team, ip }: TeamUpdaterProps) => { if (defaultUserRole !== undefined) { team.defaultUserRole = defaultUserRole; } + if (inviteRequired !== undefined) { + team.inviteRequired = inviteRequired; + } const changes = team.changed(); diff --git a/server/commands/userCreator.test.ts b/server/commands/userCreator.test.ts index 2487e2ec2..979ea7efc 100644 --- a/server/commands/userCreator.test.ts +++ b/server/commands/userCreator.test.ts @@ -184,7 +184,7 @@ describe("userCreator", () => { }); it("should create a user from an invited user", async () => { - const team = await buildTeam(); + const team = await buildTeam({ inviteRequired: true }); const invite = await buildInvite({ teamId: team.id, email: "invite@example.com", @@ -210,4 +210,33 @@ describe("userCreator", () => { expect(user.email).toEqual(invite.email); expect(isNewUser).toEqual(true); }); + + it("should reject an uninvited user when invites are required", async () => { + const team = await buildTeam({ inviteRequired: true }); + + const authenticationProviders = await team.$get("authenticationProviders"); + const authenticationProvider = authenticationProviders[0]; + let error; + + try { + await userCreator({ + name: "Uninvited User", + email: "invite@ExamPle.com", + teamId: team.id, + ip, + authentication: { + authenticationProviderId: authenticationProvider.id, + providerId: "fake-service-id", + accessToken: "123", + scopes: ["read"], + }, + }); + } catch (err) { + error = err; + } + + expect(error && error.toString()).toContain( + "You need an invite to join this team" + ); + }); }); diff --git a/server/commands/userCreator.ts b/server/commands/userCreator.ts index 4d39c9832..e55d8fad4 100644 --- a/server/commands/userCreator.ts +++ b/server/commands/userCreator.ts @@ -1,4 +1,5 @@ import { Op } from "sequelize"; +import { InviteRequiredError } from "@server/errors"; import { Event, Team, User, UserAuthentication } from "@server/models"; type UserCreatorResult = { @@ -144,9 +145,16 @@ export default async function userCreator({ try { const team = await Team.findByPk(teamId, { - attributes: ["defaultUserRole"], + attributes: ["defaultUserRole", "inviteRequired"], transaction, }); + + // If the team settings are set to require invites, and the user is not already invited, + // throw an error and fail user creation. + if (team?.inviteRequired && !invite) { + throw InviteRequiredError(); + } + const defaultUserRole = team?.defaultUserRole; const user = await User.create( diff --git a/server/errors.ts b/server/errors.ts index 338efddc2..474c56465 100644 --- a/server/errors.ts +++ b/server/errors.ts @@ -20,6 +20,14 @@ export function AuthorizationError( }); } +export function InviteRequiredError( + message = "You need an invite to join this team" +) { + return httpErrors(403, message, { + id: "invite_required", + }); +} + export function AdminRequiredError( message = "An admin role is required to access this resource" ) { diff --git a/server/migrations/20220413213537-add-inviteRequired-to-teams.js b/server/migrations/20220413213537-add-inviteRequired-to-teams.js new file mode 100644 index 000000000..5d20ac3ba --- /dev/null +++ b/server/migrations/20220413213537-add-inviteRequired-to-teams.js @@ -0,0 +1,14 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addColumn("teams", "inviteRequired", { + type: Sequelize.BOOLEAN, + defaultValue: false, + allowNull: false + }); + }, + down: async (queryInterface) => { + await queryInterface.removeColumn("teams", "inviteRequired"); + }, +}; diff --git a/server/models/Team.ts b/server/models/Team.ts index d414ce0d5..65b37ec3d 100644 --- a/server/models/Team.ts +++ b/server/models/Team.ts @@ -77,6 +77,10 @@ class Team extends ParanoidModel { @Column sharing: boolean; + @Default(false) + @Column + inviteRequired: boolean; + @Default(true) @Column(DataType.JSONB) signupQueryParams: { [key: string]: string } | null; diff --git a/server/presenters/team.ts b/server/presenters/team.ts index 60fab2cf4..d7e86c8eb 100644 --- a/server/presenters/team.ts +++ b/server/presenters/team.ts @@ -15,5 +15,6 @@ export default function present(team: Team) { domain: team.domain, url: team.url, defaultUserRole: team.defaultUserRole, + inviteRequired: team.inviteRequired, }; } diff --git a/server/routes/api/team.ts b/server/routes/api/team.ts index 0890ad384..a838bb339 100644 --- a/server/routes/api/team.ts +++ b/server/routes/api/team.ts @@ -20,6 +20,7 @@ router.post("team.update", auth(), async (ctx) => { collaborativeEditing, defaultCollectionId, defaultUserRole, + inviteRequired, } = ctx.body; const { user } = ctx.state; @@ -42,6 +43,7 @@ router.post("team.update", auth(), async (ctx) => { collaborativeEditing, defaultCollectionId, defaultUserRole, + inviteRequired, }, user, team, diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 804cd5281..0f84a115d 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -97,6 +97,9 @@ "{{ completed }} task done": "{{ completed }} task done", "{{ completed }} task done_plural": "{{ completed }} tasks 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", + "Creating a template from {{titleWithDefault}} is a non-destructive action – we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.": "Creating a template from {{titleWithDefault}} is a non-destructive action – we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.", "Currently editing": "Currently editing", "Currently viewing": "Currently viewing", "Viewed {{ timeAgo }} ago": "Viewed {{ timeAgo }} ago", @@ -239,7 +242,7 @@ "Path to document": "Path to document", "Group member options": "Group member options", "Remove": "Remove", - "Delete collection": "Delete collection", + "Delete collection": "Are you sure you want to delete this collection?", "Sort in sidebar": "Sort in sidebar", "Alphabetical sort": "Alphabetical sort", "Manual sort": "Manual sort", @@ -312,7 +315,7 @@ "Get started by creating a new one!": "Get started by creating a new one!", "Create a document": "Create a document", "Manage permissions": "Manage permissions", - "Are you sure about that? Deleting the {{collectionName}} collection is permanent and cannot be restored, however documents within will be moved to the trash.": "Are you sure about that? Deleting the {{collectionName}} collection is permanent and cannot be restored, however documents within will be moved to the trash.", + "Are you sure about that? Deleting the {{collectionName}} collection is permanent and cannot be restored, however documents within will be moved to the trash.": "Deleting the {{collectionName}} collection is permanent and cannot be restored, however documents within will be moved to the trash.", "Also, {{collectionName}} is being used as the start view – deleting it will reset the start view to the Home page.": "Also, {{collectionName}} is being used as the start view – deleting it will reset the start view to the Home page.", "Deleting": "Deleting", "I’m sure – Delete": "I’m sure – Delete", @@ -331,7 +334,6 @@ "This is the default level of access, you can give individual users or groups more access once the collection is created.": "This is the default level of access, you can give individual users or groups more access once the collection is created.", "Public document sharing": "Public document sharing", "When enabled any documents within this collection can be shared publicly on the internet.": "When enabled any documents within this collection can be shared publicly on the internet.", - "Creating": "Creating", "Create": "Create", "{{ groupName }} was added to the collection": "{{ groupName }} was added to the collection", "Could not add user": "Could not add user", @@ -428,8 +430,6 @@ "Heads up – moving the document {{ title }} to the {{ newCollectionName }} collection will grant all team members {{ newPermission }}, they currently have {{ prevPermission }}.": "Heads up – moving the document {{ title }} to the {{ newCollectionName }} collection will grant all team members {{ newPermission }}, they currently have {{ prevPermission }}.", "Moving": "Moving", "Cancel": "Cancel", - "Template created, go ahead and customize it": "Template created, go ahead and customize it", - "Creating a template from {{titleWithDefault}} is a non-destructive action – we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.": "Creating a template from {{titleWithDefault}} is a non-destructive action – we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.", "Search documents": "Search documents", "No documents found for your filters.": "No documents found for your filters.", "You’ve not got any drafts at the moment.": "You’ve not got any drafts at the moment.", @@ -616,6 +616,10 @@ "Delete Account": "Delete Account", "You may delete your account at any time, note that this is unrecoverable": "You may delete your account at any time, note that this is unrecoverable", "Delete account": "Delete account", + "Are you sure you want to disable authorized signups?": "Are you sure you want to disable authorized signups?", + "I’m sure — Disable": "I’m sure — Disable", + "Disabling": "Disabling", + "New account creation using {{ authenticationMethods }} will be disabled. New users will need to be invited.": "New account creation using {{ authenticationMethods }} will be disabled. New users will need to be invited.", "Settings that impact the access, security, and content of your knowledge base.": "Settings that impact the access, security, and content of your knowledge base.", "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", @@ -625,6 +629,8 @@ "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", "Allow members to create new collections within the knowledge base": "Allow members to create new collections within the knowledge base", + "Allow authorized signups": "Allow authorized signups", + "Allow authorized {{ authenticationMethods }} users to create new accounts without first receiving an invite": "Allow authorized {{ authenticationMethods }} users to create new accounts without first receiving an invite", "Default role": "Default role", "The default user role for new accounts. Changing this setting does not affect existing user accounts.": "The default user role for new accounts. Changing this setting does not affect existing user accounts.", "Sharing is currently disabled.": "Sharing is currently disabled.",