diff --git a/admin/app/ai/form.tsx b/admin/app/ai/form.tsx index 510566e8021..4258a99fbb2 100644 --- a/admin/app/ai/form.tsx +++ b/admin/app/ai/form.tsx @@ -121,7 +121,12 @@ export const InstanceAIForm: FC = (props) => {
-
If you have a preferred AI models vendor, please get in touch with us.
+
+ If you have a preferred AI models vendor, please get in{" "} + + touch with us. + +
diff --git a/admin/app/authentication/github/form.tsx b/admin/app/authentication/github/form.tsx index afab9a3c513..23ba9ccd155 100644 --- a/admin/app/authentication/github/form.tsx +++ b/admin/app/authentication/github/form.tsx @@ -195,7 +195,7 @@ export const InstanceGithubConfigForm: FC = (props) => { Go back diff --git a/admin/app/authentication/gitlab/form.tsx b/admin/app/authentication/gitlab/form.tsx index 2d5782e10ba..1cc9794d377 100644 --- a/admin/app/authentication/gitlab/form.tsx +++ b/admin/app/authentication/gitlab/form.tsx @@ -191,7 +191,7 @@ export const InstanceGitlabConfigForm: FC = (props) => { Go back diff --git a/admin/app/authentication/google/form.tsx b/admin/app/authentication/google/form.tsx index cf579789520..61fe7af620c 100644 --- a/admin/app/authentication/google/form.tsx +++ b/admin/app/authentication/google/form.tsx @@ -192,7 +192,7 @@ export const InstanceGoogleConfigForm: FC = (props) => { Go back diff --git a/admin/app/authentication/page.tsx b/admin/app/authentication/page.tsx index d37b35978e7..dc6aa6a6dce 100644 --- a/admin/app/authentication/page.tsx +++ b/admin/app/authentication/page.tsx @@ -60,7 +60,7 @@ const InstanceAuthenticationPage = observer(() => {
Manage authentication modes for your instance
- Configure authentication modes for your team and restrict sign ups to be invite only. + Configure authentication modes for your team and restrict sign-ups to be invite only.
@@ -80,9 +80,11 @@ const InstanceAuthenticationPage = observer(() => { { - Boolean(parseInt(enableSignUpConfig)) === true - ? updateConfig("ENABLE_SIGNUP", "0") - : updateConfig("ENABLE_SIGNUP", "1"); + if (Boolean(parseInt(enableSignUpConfig)) === true) { + updateConfig("ENABLE_SIGNUP", "0"); + } else { + updateConfig("ENABLE_SIGNUP", "1"); + } }} size="sm" disabled={isSubmitting} @@ -90,7 +92,7 @@ const InstanceAuthenticationPage = observer(() => {
-
Authentication modes
+
Available authentication modes
) : ( diff --git a/admin/app/email/email-config-form.tsx b/admin/app/email/email-config-form.tsx index 2cc24fc8969..73a1af17442 100644 --- a/admin/app/email/email-config-form.tsx +++ b/admin/app/email/email-config-form.tsx @@ -72,7 +72,7 @@ export const InstanceEmailForm: FC = (props) => { { key: "EMAIL_FROM", type: "text", - label: "Sender email address", + label: "Sender's email address", description: "This is the email address your users will see when getting emails from this instance. You will need to verify this address.", placeholder: "no-reply@projectplane.so", @@ -174,12 +174,12 @@ export const InstanceEmailForm: FC = (props) => {
-
+
-
Authentication (optional)
+
Authentication
- We recommend setting up a username password for your SMTP server + This is optional, but we recommend setting up a username and a password for your SMTP server.
diff --git a/admin/app/general/form.tsx b/admin/app/general/form.tsx index 4422ee91ffe..09aac8b4590 100644 --- a/admin/app/general/form.tsx +++ b/admin/app/general/form.tsx @@ -117,17 +117,18 @@ export const GeneralConfigurationForm: FC = observer(
- Allow Plane to collect anonymous usage events + Let Plane collect anonymous usage data
- We collect usage events without any PII to analyse and improve Plane.{" "} + No PII is collected.This anonymized data is used to understand how you use Plane and build new features + in line with{" "} - Know more. + our Telemetry Policy.
diff --git a/admin/app/general/intercom.tsx b/admin/app/general/intercom.tsx index aaeacfc0fe0..37f7e307114 100644 --- a/admin/app/general/intercom.tsx +++ b/admin/app/general/intercom.tsx @@ -60,9 +60,9 @@ export const IntercomConfig: FC = observer((props) => {
-
Talk to Plane
+
Chat with us
- Let your members chat with us via Intercom or another service. Toggling Telemetry off turns this off + Let your users chat with us via Intercom or another service. Toggling Telemetry off turns this off automatically.
diff --git a/admin/app/workspace/create/form.tsx b/admin/app/workspace/create/form.tsx new file mode 100644 index 00000000000..bc4b2d42c56 --- /dev/null +++ b/admin/app/workspace/create/form.tsx @@ -0,0 +1,212 @@ +import { useState, useEffect } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { Controller, useForm } from "react-hook-form"; +// constants +import { ORGANIZATION_SIZE, RESTRICTED_URLS } from "@plane/constants"; +// types +import { IWorkspace } from "@plane/types"; +// components +import { Button, CustomSelect, getButtonStyling, Input, setToast, TOAST_TYPE } from "@plane/ui"; +// helpers +import { WEB_BASE_URL } from "@/helpers/common.helper"; +// hooks +import { useWorkspace } from "@/hooks/store"; +// services +import { WorkspaceService } from "@/services/workspace.service"; + +const workspaceService = new WorkspaceService(); + +export const WorkspaceCreateForm = () => { + // router + const router = useRouter(); + // states + const [slugError, setSlugError] = useState(false); + const [invalidSlug, setInvalidSlug] = useState(false); + const [defaultValues, setDefaultValues] = useState>({ + name: "", + slug: "", + organization_size: "", + }); + // store hooks + const { createWorkspace } = useWorkspace(); + // form info + const { + handleSubmit, + control, + setValue, + getValues, + formState: { errors, isSubmitting, isValid }, + } = useForm({ defaultValues, mode: "onChange" }); + + const handleCreateWorkspace = async (formData: IWorkspace) => { + await workspaceService + .workspaceSlugCheck(formData.slug) + .then(async (res) => { + if (res.status === true && !RESTRICTED_URLS.includes(formData.slug)) { + setSlugError(false); + await createWorkspace(formData) + .then(async () => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Workspace created successfully.", + }); + router.push(`/workspace`); + }) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Workspace could not be created. Please try again.", + }); + }); + } else setSlugError(true); + }) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Some error occurred while creating workspace. Please try again.", + }); + }); + }; + + useEffect( + () => () => { + // when the component unmounts set the default values to whatever user typed in + setDefaultValues(getValues()); + }, + [getValues, setDefaultValues] + ); + + return ( +
+
+
+

Name your workspace

+
+ + /^[\w\s-]*$/.test(value) || + `Workspaces names can contain only (" "), ( - ), ( _ ) and alphanumeric characters.`, + maxLength: { + value: 80, + message: "Limit your name to 80 characters.", + }, + }} + render={({ field: { value, ref, onChange } }) => ( + { + onChange(e.target.value); + setValue("name", e.target.value); + setValue("slug", e.target.value.toLocaleLowerCase().trim().replace(/ /g, "-"), { + shouldValidate: true, + }); + }} + ref={ref} + hasError={Boolean(errors.name)} + placeholder="Something familiar and recognizable is always best." + className="w-full" + /> + )} + /> + {errors?.name?.message} +
+
+
+

Set your workspace's URL

+
+ {WEB_BASE_URL}/ + ( + { + if (/^[a-zA-Z0-9_-]+$/.test(e.target.value)) setInvalidSlug(false); + else setInvalidSlug(true); + onChange(e.target.value.toLowerCase()); + }} + ref={ref} + hasError={Boolean(errors.slug)} + placeholder="workspace-name" + className="block w-full rounded-md border-none bg-transparent !px-0 py-2 text-sm" + /> + )} + /> +
+ {slugError &&

This URL is taken. Try something else.

} + {invalidSlug && ( +

{`URLs can contain only ( - ), ( _ ) and alphanumeric characters.`}

+ )} + {errors.slug && {errors.slug.message}} +
+
+

How many people will use this workspace?

+
+ ( + c === value) ?? ( + Select a range + ) + } + buttonClassName="!border-[0.5px] !border-custom-border-200 !shadow-none" + input + optionsClassName="w-full" + > + {ORGANIZATION_SIZE.map((item) => ( + + {item} + + ))} + + )} + /> + {errors.organization_size && ( + {errors.organization_size.message} + )} +
+
+
+
+ + + Go back + +
+
+ ); +}; diff --git a/admin/app/workspace/create/page.tsx b/admin/app/workspace/create/page.tsx new file mode 100644 index 00000000000..0186286a7b4 --- /dev/null +++ b/admin/app/workspace/create/page.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { observer } from "mobx-react"; +// components +import { WorkspaceCreateForm } from "./form"; + +const WorkspaceCreatePage = observer(() => ( +
+
+
Create a new workspace on this instance.
+
+ You will need to invite users from Workspace Settings after you create this workspace. +
+
+
+ +
+
+)); + +export default WorkspaceCreatePage; diff --git a/admin/app/workspace/layout.tsx b/admin/app/workspace/layout.tsx new file mode 100644 index 00000000000..9f2a63c67d5 --- /dev/null +++ b/admin/app/workspace/layout.tsx @@ -0,0 +1,12 @@ +import { ReactNode } from "react"; +import { Metadata } from "next"; +// layouts +import { AdminLayout } from "@/layouts/admin-layout"; + +export const metadata: Metadata = { + title: "Workspace Management - Plane Web", +}; + +export default function WorkspaceManagementLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/admin/app/workspace/page.tsx b/admin/app/workspace/page.tsx new file mode 100644 index 00000000000..ef8a3c42d28 --- /dev/null +++ b/admin/app/workspace/page.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +import useSWR from "swr"; +import { Loader as LoaderIcon } from "lucide-react"; +// types +import { TInstanceConfigurationKeys } from "@plane/types"; +// ui +import { Button, getButtonStyling, Loader, setPromiseToast, ToggleSwitch } from "@plane/ui"; +// components +import { WorkspaceListItem } from "@/components/workspace"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useInstance, useWorkspace } from "@/hooks/store"; + +const WorkspaceManagementPage = observer(() => { + // states + const [isSubmitting, setIsSubmitting] = useState(false); + // store + const { formattedConfig, fetchInstanceConfigurations, updateInstanceConfigurations } = useInstance(); + const { + workspaceIds, + loader: workspaceLoader, + paginationInfo, + fetchWorkspaces, + fetchNextWorkspaces, + } = useWorkspace(); + // derived values + const disableWorkspaceCreation = formattedConfig?.DISABLE_WORKSPACE_CREATION ?? ""; + const hasNextPage = paginationInfo?.next_page_results && paginationInfo?.next_cursor !== undefined; + + // fetch data + useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); + useSWR("INSTANCE_WORKSPACES", () => fetchWorkspaces()); + + const updateConfig = async (key: TInstanceConfigurationKeys, value: string) => { + setIsSubmitting(true); + + const payload = { + [key]: value, + }; + + const updateConfigPromise = updateInstanceConfigurations(payload); + + setPromiseToast(updateConfigPromise, { + loading: "Saving configuration", + success: { + title: "Success", + message: () => "Configuration saved successfully", + }, + error: { + title: "Error", + message: () => "Failed to save configuration", + }, + }); + + await updateConfigPromise + .then(() => { + setIsSubmitting(false); + }) + .catch((err) => { + console.error(err); + setIsSubmitting(false); + }); + }; + + return ( +
+
+
+
Workspaces on this instance
+
+ See all workspaces and control who can create them. +
+
+
+
+
+ {formattedConfig ? ( +
+
+
+
Prevent anyone else from creating a workspace.
+
+ Toggling this on will let only you create workspaces. You will have to invite users to new + workspaces. +
+
+
+
+
+ { + if (Boolean(parseInt(disableWorkspaceCreation)) === true) { + updateConfig("DISABLE_WORKSPACE_CREATION", "0"); + } else { + updateConfig("DISABLE_WORKSPACE_CREATION", "1"); + } + }} + size="sm" + disabled={isSubmitting} + /> +
+
+
+ ) : ( + + + + )} + {workspaceLoader !== "init-loader" ? ( + <> +
+
+
+ All workspaces on this instance{" "} + • {workspaceIds.length} + {workspaceLoader && ["mutation", "pagination"].includes(workspaceLoader) && ( + + )} +
+
+ You can't yet delete workspaces and you can only go to the workspace if you are an Admin or a + Member. +
+
+
+ + Create workspace + +
+
+
+ {workspaceIds.map((workspaceId) => ( + + ))} +
+ {hasNextPage && ( +
+ +
+ )} + + ) : ( + + + + + + + )} +
+
+
+ ); +}); + +export default WorkspaceManagementPage; diff --git a/admin/ce/components/common/upgrade-button.tsx b/admin/ce/components/common/upgrade-button.tsx index aa3c95fdbed..c2b264baeb9 100644 --- a/admin/ce/components/common/upgrade-button.tsx +++ b/admin/ce/components/common/upgrade-button.tsx @@ -9,8 +9,8 @@ import { getButtonStyling } from "@plane/ui"; import { cn } from "@/helpers/common.helper"; export const UpgradeButton: React.FC = () => ( - - Available on One + + Upgrade ); diff --git a/admin/core/components/admin-sidebar/help-section.tsx b/admin/core/components/admin-sidebar/help-section.tsx index abba68e3eae..10d5cbd0dad 100644 --- a/admin/core/components/admin-sidebar/help-section.tsx +++ b/admin/core/components/admin-sidebar/help-section.tsx @@ -52,13 +52,13 @@ export const HelpSection: FC = observer(() => { )} >
- + - {!isSidebarCollapsed && "Redirect to plane"} + {!isSidebarCollapsed && "Redirect to Plane"} diff --git a/admin/core/components/admin-sidebar/sidebar-menu.tsx b/admin/core/components/admin-sidebar/sidebar-menu.tsx index c0f1d0bf22a..a985842e7f3 100644 --- a/admin/core/components/admin-sidebar/sidebar-menu.tsx +++ b/admin/core/components/admin-sidebar/sidebar-menu.tsx @@ -4,7 +4,7 @@ import { observer } from "mobx-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react"; -import { Tooltip } from "@plane/ui"; +import { Tooltip, WorkspaceIcon } from "@plane/ui"; // hooks import { cn } from "@/helpers/common.helper"; import { useTheme } from "@/hooks/store"; @@ -14,31 +14,37 @@ const INSTANCE_ADMIN_LINKS = [ { Icon: Cog, name: "General", - description: "Identify your instances and get key details", + description: "Identify your instances and get key details.", href: `/general/`, }, + { + Icon: WorkspaceIcon, + name: "Workspaces", + description: "Manage all workspaces on this instance.", + href: `/workspace/`, + }, { Icon: Mail, name: "Email", - description: "Set up emails to your users", + description: "Configure your SMTP controls.", href: `/email/`, }, { Icon: Lock, name: "Authentication", - description: "Configure authentication modes", + description: "Configure authentication modes.", href: `/authentication/`, }, { Icon: BrainCog, name: "Artificial intelligence", - description: "Configure your OpenAI creds", + description: "Configure your OpenAI creds.", href: `/ai/`, }, { Icon: Image, name: "Images in Plane", - description: "Allow third-party image libraries", + description: "Allow third-party image libraries.", href: `/image/`, }, ]; diff --git a/admin/core/components/auth-header.tsx b/admin/core/components/auth-header.tsx index e1de884cfc9..81365d7f0cf 100644 --- a/admin/core/components/auth-header.tsx +++ b/admin/core/components/auth-header.tsx @@ -33,6 +33,10 @@ export const InstanceHeader: FC = observer(() => { return "Github"; case "gitlab": return "GitLab"; + case "workspace": + return "Workspace"; + case "create": + return "Create"; default: return pathName.toUpperCase(); } diff --git a/admin/core/components/new-user-popup.tsx b/admin/core/components/new-user-popup.tsx index 9fc3938ff3a..8e1570781c4 100644 --- a/admin/core/components/new-user-popup.tsx +++ b/admin/core/components/new-user-popup.tsx @@ -1,13 +1,13 @@ "use client"; import React from "react"; +import { resolveGeneralTheme } from "helpers/common.helper"; import { observer } from "mobx-react"; import Image from "next/image"; +import Link from "next/link"; import { useTheme as nextUseTheme } from "next-themes"; // ui import { Button, getButtonStyling } from "@plane/ui"; -// helpers -import { WEB_BASE_URL, resolveGeneralTheme } from "helpers/common.helper"; // hooks import { useTheme } from "@/hooks/store"; // icons @@ -20,8 +20,6 @@ export const NewUserPopup: React.FC = observer(() => { // theme const { resolvedTheme } = nextUseTheme(); - const redirectionLink = encodeURI(WEB_BASE_URL + "/create-workspace"); - if (!isNewUserPopup) return <>; return (
@@ -30,12 +28,12 @@ export const NewUserPopup: React.FC = observer(() => {
Create workspace
Instance setup done! Welcome to Plane instance portal. Start your journey with by creating your first - workspace, you will need to login again. + workspace.
- + Create workspace - + diff --git a/admin/core/components/workspace/index.ts b/admin/core/components/workspace/index.ts new file mode 100644 index 00000000000..24950c4f20f --- /dev/null +++ b/admin/core/components/workspace/index.ts @@ -0,0 +1 @@ +export * from "./list-item"; diff --git a/admin/core/components/workspace/list-item.tsx b/admin/core/components/workspace/list-item.tsx new file mode 100644 index 00000000000..9289140f7dc --- /dev/null +++ b/admin/core/components/workspace/list-item.tsx @@ -0,0 +1,82 @@ +import { observer } from "mobx-react"; +import Link from "next/link"; +import { ExternalLink } from "lucide-react"; +// helpers +import { Tooltip } from "@plane/ui"; +import { WEB_BASE_URL } from "@/helpers/common.helper"; +import { getFileURL } from "@/helpers/file.helper"; +// hooks +import { useWorkspace } from "@/hooks/store"; + +type TWorkspaceListItemProps = { + workspaceId: string; +}; + +export const WorkspaceListItem = observer(({ workspaceId }: TWorkspaceListItemProps) => { + // store hooks + const { getWorkspaceById } = useWorkspace(); + // derived values + const workspace = getWorkspaceById(workspaceId); + + if (!workspace) return null; + return ( + +
+ + {workspace?.logo_url && workspace.logo_url !== "" ? ( + Workspace Logo + ) : ( + (workspace?.name?.[0] ?? "...") + )} + +
+
+

{workspace.name}

/ + +

[{workspace.slug}]

+
+
+ {workspace.owner.email && ( +
+

Owned by:

+

{workspace.owner.email}

+
+ )} +
+ {workspace.total_projects !== null && ( + +

Total projects:

+

{workspace.total_projects}

+
+ )} + {workspace.total_members !== null && ( + <> + • + +

Total members:

+

{workspace.total_members}

+
+ + )} +
+
+
+
+ +
+ + ); +}); diff --git a/admin/core/hooks/store/index.ts b/admin/core/hooks/store/index.ts index 7447064da70..ed1781299f9 100644 --- a/admin/core/hooks/store/index.ts +++ b/admin/core/hooks/store/index.ts @@ -1,3 +1,4 @@ export * from "./use-theme"; export * from "./use-instance"; export * from "./use-user"; +export * from "./use-workspace"; diff --git a/admin/core/hooks/store/use-workspace.tsx b/admin/core/hooks/store/use-workspace.tsx new file mode 100644 index 00000000000..e3bde92d530 --- /dev/null +++ b/admin/core/hooks/store/use-workspace.tsx @@ -0,0 +1,10 @@ +import { useContext } from "react"; +// store +import { StoreContext } from "@/lib/store-provider"; +import { IWorkspaceStore } from "@/store/workspace.store"; + +export const useWorkspace = (): IWorkspaceStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useWorkspace must be used within StoreProvider"); + return context.workspace; +}; diff --git a/admin/core/services/workspace.service.ts b/admin/core/services/workspace.service.ts new file mode 100644 index 00000000000..1de24fd9b00 --- /dev/null +++ b/admin/core/services/workspace.service.ts @@ -0,0 +1,52 @@ +// types +import type { IWorkspace, TWorkspacePaginationInfo } from "@plane/types"; +// helpers +import { API_BASE_URL } from "@/helpers/common.helper"; +// services +import { APIService } from "@/services/api.service"; + +export class WorkspaceService extends APIService { + constructor() { + super(API_BASE_URL); + } + + /** + * @description Fetches all workspaces + * @returns Promise + */ + async getWorkspaces(nextPageCursor?: string): Promise { + return this.get("/api/instances/workspaces/", { + cursor: nextPageCursor, + }) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * @description Checks if a slug is available + * @param slug - string + * @returns Promise + */ + async workspaceSlugCheck(slug: string): Promise { + return this.get(`/api/instances/workspace-slug-check/?slug=${slug}`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * @description Creates a new workspace + * @param data - IWorkspace + * @returns Promise + */ + async createWorkspace(data: IWorkspace): Promise { + return this.post("/api/instances/workspaces/", data) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/admin/core/store/root.store.ts b/admin/core/store/root.store.ts index 4b25bcc6868..8c53061ab91 100644 --- a/admin/core/store/root.store.ts +++ b/admin/core/store/root.store.ts @@ -3,6 +3,7 @@ import { enableStaticRendering } from "mobx-react"; import { IInstanceStore, InstanceStore } from "./instance.store"; import { IThemeStore, ThemeStore } from "./theme.store"; import { IUserStore, UserStore } from "./user.store"; +import { IWorkspaceStore, WorkspaceStore } from "./workspace.store"; enableStaticRendering(typeof window === "undefined"); @@ -10,17 +11,20 @@ export abstract class CoreRootStore { theme: IThemeStore; instance: IInstanceStore; user: IUserStore; + workspace: IWorkspaceStore; constructor() { this.theme = new ThemeStore(this); this.instance = new InstanceStore(this); this.user = new UserStore(this); + this.workspace = new WorkspaceStore(this); } hydrate(initialData: any) { this.theme.hydrate(initialData.theme); this.instance.hydrate(initialData.instance); this.user.hydrate(initialData.user); + this.workspace.hydrate(initialData.workspace); } resetOnSignOut() { @@ -28,5 +32,6 @@ export abstract class CoreRootStore { this.instance = new InstanceStore(this); this.user = new UserStore(this); this.theme = new ThemeStore(this); + this.workspace = new WorkspaceStore(this); } } diff --git a/admin/core/store/workspace.store.ts b/admin/core/store/workspace.store.ts new file mode 100644 index 00000000000..dafe96760b4 --- /dev/null +++ b/admin/core/store/workspace.store.ts @@ -0,0 +1,150 @@ +import set from "lodash/set"; +import { action, observable, runInAction, makeObservable, computed } from "mobx"; +import { IWorkspace, TLoader, TPaginationInfo } from "@plane/types"; +// services +import { WorkspaceService } from "@/services/workspace.service"; +// root store +import { CoreRootStore } from "@/store/root.store"; + +export interface IWorkspaceStore { + // observables + loader: TLoader; + workspaces: Record; + paginationInfo: TPaginationInfo | undefined; + // computed + workspaceIds: string[]; + // helper actions + hydrate: (data: any) => void; + getWorkspaceById: (workspaceId: string) => IWorkspace | undefined; + // fetch actions + fetchWorkspaces: () => Promise; + fetchNextWorkspaces: () => Promise; + // curd actions + createWorkspace: (data: IWorkspace) => Promise; +} + +export class WorkspaceStore implements IWorkspaceStore { + // observables + loader: TLoader = "init-loader"; + workspaces: Record = {}; + paginationInfo: TPaginationInfo | undefined = undefined; + // services + workspaceService; + + constructor(private store: CoreRootStore) { + makeObservable(this, { + // observables + loader: observable, + workspaces: observable, + paginationInfo: observable, + // computed + workspaceIds: computed, + // helper actions + hydrate: action, + getWorkspaceById: action, + // fetch actions + fetchWorkspaces: action, + fetchNextWorkspaces: action, + // curd actions + createWorkspace: action, + }); + this.workspaceService = new WorkspaceService(); + } + + // computed + get workspaceIds() { + return Object.keys(this.workspaces); + } + + // helper actions + /** + * @description Hydrates the workspaces + * @param data - any + */ + hydrate = (data: any) => { + if (data) this.workspaces = data; + }; + + /** + * @description Gets a workspace by id + * @param workspaceId - string + * @returns IWorkspace | undefined + */ + getWorkspaceById = (workspaceId: string) => this.workspaces[workspaceId]; + + // fetch actions + /** + * @description Fetches all workspaces + * @returns Promise<> + */ + fetchWorkspaces = async (): Promise => { + try { + if (this.workspaceIds.length > 0) { + this.loader = "mutation"; + } else { + this.loader = "init-loader"; + } + const paginatedWorkspaceData = await this.workspaceService.getWorkspaces(); + runInAction(() => { + const { results, ...paginationInfo } = paginatedWorkspaceData; + results.forEach((workspace: IWorkspace) => { + set(this.workspaces, [workspace.id], workspace); + }); + set(this, "paginationInfo", paginationInfo); + }); + return paginatedWorkspaceData.results; + } catch (error) { + console.error("Error fetching workspaces", error); + throw error; + } finally { + this.loader = "loaded"; + } + }; + + /** + * @description Fetches the next page of workspaces + * @returns Promise + */ + fetchNextWorkspaces = async (): Promise => { + if (!this.paginationInfo || this.paginationInfo.next_page_results === false) return []; + try { + this.loader = "pagination"; + const paginatedWorkspaceData = await this.workspaceService.getWorkspaces(this.paginationInfo.next_cursor); + runInAction(() => { + const { results, ...paginationInfo } = paginatedWorkspaceData; + results.forEach((workspace: IWorkspace) => { + set(this.workspaces, [workspace.id], workspace); + }); + set(this, "paginationInfo", paginationInfo); + }); + return paginatedWorkspaceData.results; + } catch (error) { + console.error("Error fetching next workspaces", error); + throw error; + } finally { + this.loader = "loaded"; + } + }; + + // curd actions + /** + * @description Creates a new workspace + * @param data - IWorkspace + * @returns Promise + */ + createWorkspace = async (data: IWorkspace): Promise => { + try { + this.loader = "mutation"; + const workspace = await this.workspaceService.createWorkspace(data); + runInAction(() => { + set(this.workspaces, [workspace.id], workspace); + }); + return workspace; + } catch (error) { + console.error("Error creating workspace", error); + throw error; + } finally { + this.loader = "loaded"; + } + }; +} diff --git a/apiserver/plane/app/views/workspace/base.py b/apiserver/plane/app/views/workspace/base.py index 6ffb643a991..63c482f2071 100644 --- a/apiserver/plane/app/views/workspace/base.py +++ b/apiserver/plane/app/views/workspace/base.py @@ -1,6 +1,7 @@ # Python imports import csv import io +import os from datetime import date from dateutil.relativedelta import relativedelta @@ -38,7 +39,7 @@ from django.views.decorators.cache import cache_control from django.views.decorators.vary import vary_on_cookie from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS - +from plane.license.utils.instance_value import get_configuration_value class WorkSpaceViewSet(BaseViewSet): model = Workspace @@ -80,6 +81,21 @@ def get_queryset(self): def create(self, request): try: + DISABLE_WORKSPACE_CREATION, = get_configuration_value( + [ + { + "key": "DISABLE_WORKSPACE_CREATION", + "default": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"), + }, + ] + ) + + if DISABLE_WORKSPACE_CREATION == "1": + return Response( + {"error": "Workspace creation is not allowed"}, + status=status.HTTP_403_FORBIDDEN, + ) + serializer = WorkSpaceSerializer(data=request.data) slug = request.data.get("slug", False) diff --git a/apiserver/plane/license/api/serializers/__init__.py b/apiserver/plane/license/api/serializers/__init__.py index 681dbeb5ca4..48ecd4536a9 100644 --- a/apiserver/plane/license/api/serializers/__init__.py +++ b/apiserver/plane/license/api/serializers/__init__.py @@ -2,3 +2,4 @@ from .configuration import InstanceConfigurationSerializer from .admin import InstanceAdminSerializer, InstanceAdminMeSerializer +from .workspace import WorkspaceSerializer \ No newline at end of file diff --git a/apiserver/plane/license/api/serializers/user.py b/apiserver/plane/license/api/serializers/user.py new file mode 100644 index 00000000000..8935a882f2b --- /dev/null +++ b/apiserver/plane/license/api/serializers/user.py @@ -0,0 +1,6 @@ +from .base import BaseSerializer +from plane.db.models import User +class UserLiteSerializer(BaseSerializer): + class Meta: + model = User + fields = ["id", "email", "first_name", "last_name",] diff --git a/apiserver/plane/license/api/serializers/workspace.py b/apiserver/plane/license/api/serializers/workspace.py new file mode 100644 index 00000000000..1d3bfa8905b --- /dev/null +++ b/apiserver/plane/license/api/serializers/workspace.py @@ -0,0 +1,34 @@ +# Third Party Imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from .user import UserLiteSerializer +from plane.db.models import Workspace +from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS + + +class WorkspaceSerializer(BaseSerializer): + owner = UserLiteSerializer(read_only=True) + logo_url = serializers.CharField(read_only=True) + total_projects = serializers.IntegerField(read_only=True) + total_members = serializers.IntegerField(read_only=True) + + def validate_slug(self, value): + # Check if the slug is restricted + if value in RESTRICTED_WORKSPACE_SLUGS: + raise serializers.ValidationError("Slug is not valid") + return value + + class Meta: + model = Workspace + fields = "__all__" + read_only_fields = [ + "id", + "created_by", + "updated_by", + "created_at", + "updated_at", + "owner", + "logo_url", + ] diff --git a/apiserver/plane/license/api/views/__init__.py b/apiserver/plane/license/api/views/__init__.py index 9427ed15e59..d57ebf52c09 100644 --- a/apiserver/plane/license/api/views/__init__.py +++ b/apiserver/plane/license/api/views/__init__.py @@ -14,3 +14,5 @@ ) from .changelog import ChangeLogEndpoint + +from .workspace import InstanceWorkSpaceAvailabilityCheckEndpoint, InstanceWorkSpaceEndpoint diff --git a/apiserver/plane/license/api/views/instance.py b/apiserver/plane/license/api/views/instance.py index 883fb7c9722..0e2b64fc9b0 100644 --- a/apiserver/plane/license/api/views/instance.py +++ b/apiserver/plane/license/api/views/instance.py @@ -45,6 +45,7 @@ def get(self, request): # Get all the configuration ( ENABLE_SIGNUP, + DISABLE_WORKSPACE_CREATION, IS_GOOGLE_ENABLED, IS_GITHUB_ENABLED, GITHUB_APP_NAME, @@ -65,6 +66,10 @@ def get(self, request): "key": "ENABLE_SIGNUP", "default": os.environ.get("ENABLE_SIGNUP", "0"), }, + { + "key": "DISABLE_WORKSPACE_CREATION", + "default": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"), + }, { "key": "IS_GOOGLE_ENABLED", "default": os.environ.get("IS_GOOGLE_ENABLED", "0"), @@ -125,6 +130,7 @@ def get(self, request): data = {} # Authentication data["enable_signup"] = ENABLE_SIGNUP == "1" + data["is_workspace_creation_disabled"] = DISABLE_WORKSPACE_CREATION == "1" data["is_google_enabled"] = IS_GOOGLE_ENABLED == "1" data["is_github_enabled"] = IS_GITHUB_ENABLED == "1" data["is_gitlab_enabled"] = IS_GITLAB_ENABLED == "1" diff --git a/apiserver/plane/license/api/views/workspace.py b/apiserver/plane/license/api/views/workspace.py new file mode 100644 index 00000000000..af9e8773ad3 --- /dev/null +++ b/apiserver/plane/license/api/views/workspace.py @@ -0,0 +1,115 @@ +# Third party imports +from rest_framework.response import Response +from rest_framework import status +from django.db import IntegrityError +from django.db.models import OuterRef, Func, F + +# Module imports +from plane.app.views.base import BaseAPIView +from plane.license.api.permissions import InstanceAdminPermission +from plane.db.models import Workspace, WorkspaceMember, Project +from plane.license.api.serializers import WorkspaceSerializer +from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS + + +class InstanceWorkSpaceAvailabilityCheckEndpoint(BaseAPIView): + permission_classes = [InstanceAdminPermission] + + def get(self, request): + slug = request.GET.get("slug", False) + + if not slug or slug == "": + return Response( + {"error": "Workspace Slug is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + workspace = ( + Workspace.objects.filter(slug=slug).exists() + or slug in RESTRICTED_WORKSPACE_SLUGS + ) + return Response({"status": not workspace}, status=status.HTTP_200_OK) + + +class InstanceWorkSpaceEndpoint(BaseAPIView): + model = Workspace + serializer_class = WorkspaceSerializer + permission_classes = [InstanceAdminPermission] + + def get(self, request): + project_count = ( + Project.objects.filter(workspace_id=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + + member_count = ( + WorkspaceMember.objects.filter( + workspace=OuterRef("id"), member__is_bot=False, is_active=True + ).select_related("owner") + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + + workspaces = Workspace.objects.annotate( + total_projects=project_count, + total_members=member_count, + ) + + # Add search functionality + search = request.query_params.get("search", None) + if search: + workspaces = workspaces.filter(name__icontains=search) + + return self.paginate( + request=request, + queryset=workspaces, + on_results=lambda results: WorkspaceSerializer( + results, many=True, + ).data, + max_per_page=10, + default_per_page=10, + ) + + def post(self, request): + try: + serializer = WorkspaceSerializer (data=request.data) + + slug = request.data.get("slug", False) + name = request.data.get("name", False) + + if not name or not slug: + return Response( + {"error": "Both name and slug are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if len(name) > 80 or len(slug) > 48: + return Response( + {"error": "The maximum length for name is 80 and for slug is 48"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if serializer.is_valid(raise_exception=True): + serializer.save(owner=request.user) + # Create Workspace member + _ = WorkspaceMember.objects.create( + workspace_id=serializer.data["id"], + member=request.user, + role=20, + company_role=request.data.get("company_role", ""), + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response( + [serializer.errors[error][0] for error in serializer.errors], + status=status.HTTP_400_BAD_REQUEST, + ) + + except IntegrityError as e: + if "already exists" in str(e): + return Response( + {"slug": "The workspace with the slug already exists"}, + status=status.HTTP_410_GONE, + ) diff --git a/apiserver/plane/license/management/commands/configure_instance.py b/apiserver/plane/license/management/commands/configure_instance.py index a1c27851e32..548c9c77ed0 100644 --- a/apiserver/plane/license/management/commands/configure_instance.py +++ b/apiserver/plane/license/management/commands/configure_instance.py @@ -29,6 +29,12 @@ def handle(self, *args, **options): "category": "AUTHENTICATION", "is_encrypted": False, }, + { + "key": "DISABLE_WORKSPACE_CREATION", + "value": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"), + "category": "WORKSPACE_MANAGEMENT", + "is_encrypted": False, + }, { "key": "ENABLE_EMAIL_PASSWORD", "value": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"), diff --git a/apiserver/plane/license/urls.py b/apiserver/plane/license/urls.py index 50e6d0de896..842af09595f 100644 --- a/apiserver/plane/license/urls.py +++ b/apiserver/plane/license/urls.py @@ -12,6 +12,8 @@ InstanceAdminSignOutEndpoint, InstanceAdminUserSessionEndpoint, ChangeLogEndpoint, + InstanceWorkSpaceAvailabilityCheckEndpoint, + InstanceWorkSpaceEndpoint, ) urlpatterns = [ @@ -55,4 +57,14 @@ EmailCredentialCheckEndpoint.as_view(), name="email-credential-check", ), + path( + "workspace-slug-check/", + InstanceWorkSpaceAvailabilityCheckEndpoint.as_view(), + name="instance-workspace-availability", + ), + path( + "workspaces/", + InstanceWorkSpaceEndpoint.as_view(), + name="instance-workspace", + ), ] diff --git a/packages/constants/index.ts b/packages/constants/index.ts index 66089416f1e..85e95bf4e0c 100644 --- a/packages/constants/index.ts +++ b/packages/constants/index.ts @@ -1,2 +1,3 @@ export * from "./auth"; -export * from "./issue"; \ No newline at end of file +export * from "./issue"; +export * from "./workspace"; diff --git a/packages/constants/workspace.ts b/packages/constants/workspace.ts new file mode 100644 index 00000000000..32f36de1b96 --- /dev/null +++ b/packages/constants/workspace.ts @@ -0,0 +1,23 @@ +export const ORGANIZATION_SIZE = [ + "Just myself", + "2-10", + "11-50", + "51-200", + "201-500", + "500+", +]; + +export const RESTRICTED_URLS = [ + "404", + "accounts", + "api", + "create-workspace", + "error", + "god-mode", + "installations", + "invitations", + "onboarding", + "profile", + "spaces", + "workspace-invitations", +]; diff --git a/packages/types/src/instance/base.d.ts b/packages/types/src/instance/base.d.ts index 8095f4e01f3..41198b27c9c 100644 --- a/packages/types/src/instance/base.d.ts +++ b/packages/types/src/instance/base.d.ts @@ -4,6 +4,7 @@ import { TInstanceEmailConfigurationKeys, TInstanceImageConfigurationKeys, TInstanceAuthenticationKeys, + TInstanceWorkspaceConfigurationKeys, } from "./"; export interface IInstanceInfo { @@ -36,6 +37,7 @@ export interface IInstance { } export interface IInstanceConfig { + is_workspace_creation_disabled: boolean; is_google_enabled: boolean; is_github_enabled: boolean; is_gitlab_enabled: boolean; @@ -78,7 +80,8 @@ export type TInstanceConfigurationKeys = | TInstanceEmailConfigurationKeys | TInstanceImageConfigurationKeys | TInstanceAuthenticationKeys - | TInstanceIntercomConfigurationKeys; + | TInstanceIntercomConfigurationKeys + | TInstanceWorkspaceConfigurationKeys; export interface IInstanceConfiguration { id: string; diff --git a/packages/types/src/instance/index.d.ts b/packages/types/src/instance/index.d.ts index c68f196d35d..bc6474b832f 100644 --- a/packages/types/src/instance/index.d.ts +++ b/packages/types/src/instance/index.d.ts @@ -3,3 +3,4 @@ export * from "./auth"; export * from "./base"; export * from "./email"; export * from "./image"; +export * from "./workspace"; diff --git a/packages/types/src/instance/workspace.d.ts b/packages/types/src/instance/workspace.d.ts new file mode 100644 index 00000000000..15a7317d002 --- /dev/null +++ b/packages/types/src/instance/workspace.d.ts @@ -0,0 +1 @@ +export type TInstanceWorkspaceConfigurationKeys = "DISABLE_WORKSPACE_CREATION"; diff --git a/packages/types/src/workspace.d.ts b/packages/types/src/workspace.d.ts index 412083c4288..500eaa7b513 100644 --- a/packages/types/src/workspace.d.ts +++ b/packages/types/src/workspace.d.ts @@ -21,6 +21,7 @@ export interface IWorkspace { readonly updated_by: string; organization_size: string; total_issues: number; + total_projects?: number; } export interface IWorkspaceLite { @@ -222,3 +223,7 @@ export interface IWorkspaceProgressResponse { export interface IWorkspaceAnalyticsResponse { completion_chart: any; } + +export type TWorkspacePaginationInfo = TPaginationInfo & { + results: IWorkspace[]; +}; diff --git a/packages/ui/src/icons/index.ts b/packages/ui/src/icons/index.ts index d1cc6af038d..91ae0e2f190 100644 --- a/packages/ui/src/icons/index.ts +++ b/packages/ui/src/icons/index.ts @@ -33,3 +33,4 @@ export * from "./in-progress-icon"; export * from "./done-icon"; export * from "./pending-icon"; export * from "./pi-chat"; +export * from "./workspace-icon"; diff --git a/packages/ui/src/icons/workspace-icon.tsx b/packages/ui/src/icons/workspace-icon.tsx new file mode 100644 index 00000000000..07872b264ca --- /dev/null +++ b/packages/ui/src/icons/workspace-icon.tsx @@ -0,0 +1,14 @@ +import * as React from "react"; + +import { ISvgIcons } from "./type"; + +export const WorkspaceIcon: React.FC = ({ className }) => ( + + + +); diff --git a/web/app/create-workspace/page.tsx b/web/app/create-workspace/page.tsx index 1a5625ad646..36bc8978ad2 100644 --- a/web/app/create-workspace/page.tsx +++ b/web/app/create-workspace/page.tsx @@ -7,15 +7,19 @@ import Link from "next/link"; import { useTheme } from "next-themes"; import { IWorkspace } from "@plane/types"; // components +import { Button, getButtonStyling } from "@plane/ui"; import { CreateWorkspaceForm } from "@/components/workspace"; // hooks import { useUser, useUserProfile } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; // wrappers import { AuthenticationWrapper } from "@/lib/wrappers"; +// plane web helpers +import { getIsWorkspaceCreationDisabled } from "@/plane-web/helpers/instance.helper"; // images import BlackHorizontalLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png"; import WhiteHorizontalLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png"; +import WorkspaceCreationDisabled from "@/public/workspace/workspace-creation-disabled.png"; const CreateWorkspacePage = observer(() => { // router @@ -31,6 +35,8 @@ const CreateWorkspacePage = observer(() => { }); // hooks const { resolvedTheme } = useTheme(); + // derived values + const isWorkspaceCreationDisabled = getIsWorkspaceCreationDisabled(); const onSubmit = async (workspace: IWorkspace) => { await updateUserProfile({ last_workspace_id: workspace.id }).then(() => router.push(`/${workspace.slug}`)); @@ -56,16 +62,38 @@ const CreateWorkspacePage = observer(() => {
-
-

Create your workspace

-
- + {isWorkspaceCreationDisabled ? ( +
+ Workspace creation disabled +
Only your instance admin can create workspaces
+

+ If you know your instance admin's email address,
click the button below to get in touch with + them. +

+
+ + + Request instance admin + +
-
+ ) : ( +
+

Create your workspace

+
+ +
+
+ )}
diff --git a/web/ce/helpers/instance.helper.ts b/web/ce/helpers/instance.helper.ts new file mode 100644 index 00000000000..622ef4af7e3 --- /dev/null +++ b/web/ce/helpers/instance.helper.ts @@ -0,0 +1,7 @@ +import { store } from "@/lib/store-context"; + +export const getIsWorkspaceCreationDisabled = () => { + const instanceConfig = store.instance.config; + + return instanceConfig?.is_workspace_creation_disabled; +}; diff --git a/web/core/components/account/auth-forms/auth-header.tsx b/web/core/components/account/auth-forms/auth-header.tsx index 424fcbfe64b..4f0949aa28c 100644 --- a/web/core/components/account/auth-forms/auth-header.tsx +++ b/web/core/components/account/auth-forms/auth-header.tsx @@ -21,30 +21,30 @@ type TAuthHeader = { const Titles = { [EAuthModes.SIGN_IN]: { [EAuthSteps.EMAIL]: { - header: "Log in or Sign up", + header: "Log in or sign up", subHeader: "", }, [EAuthSteps.PASSWORD]: { - header: "Log in or Sign up", - subHeader: "Log in using your password.", + header: "Log in or sign up", + subHeader: "Use your email-password combination to log in.", }, [EAuthSteps.UNIQUE_CODE]: { header: "Log in or Sign up", - subHeader: "Log in using your unique code.", + subHeader: "Log in using a unique code sent to the email address above.", }, }, [EAuthModes.SIGN_UP]: { [EAuthSteps.EMAIL]: { - header: "Sign up or Log in", + header: "Sign up", subHeader: "", }, [EAuthSteps.PASSWORD]: { - header: "Sign up or Log in", - subHeader: "Sign up using your password", + header: "Sign up", + subHeader: "Sign up using an email-password combination.", }, [EAuthSteps.UNIQUE_CODE]: { - header: "Sign up or Log in", - subHeader: "Sign up using your unique code", + header: "Sign up", + subHeader: "Sign up using a unique code sent to the email address above.", }, }, }; diff --git a/web/core/components/account/auth-forms/email.tsx b/web/core/components/account/auth-forms/email.tsx index 1e96ed3f2f5..f0b88407a83 100644 --- a/web/core/components/account/auth-forms/email.tsx +++ b/web/core/components/account/auth-forms/email.tsx @@ -64,7 +64,7 @@ export const AuthEmailForm: FC = observer((props) => { type="email" value={email} onChange={(e) => setEmail(e.target.value)} - placeholder="name@example.com" + placeholder="name@company.com" className={`disable-autofill-style h-[46px] w-full placeholder:text-onboarding-text-400 autofill:bg-red-500 border-0 focus:bg-none active:bg-transparent`} autoComplete="on" autoFocus diff --git a/web/core/components/onboarding/create-or-join-workspaces.tsx b/web/core/components/onboarding/create-or-join-workspaces.tsx index 3bfc71a2be2..f295a1d5c36 100644 --- a/web/core/components/onboarding/create-or-join-workspaces.tsx +++ b/web/core/components/onboarding/create-or-join-workspaces.tsx @@ -4,11 +4,14 @@ import Image from "next/image"; // icons import { useTheme } from "next-themes"; // types +import { OctagonAlert } from "lucide-react"; import { IWorkspaceMemberInvitation, TOnboardingSteps } from "@plane/types"; // components import { Invitations, OnboardingHeader, SwitchAccountDropdown, CreateWorkspace } from "@/components/onboarding"; // hooks import { useUser } from "@/hooks/store"; +// plane web helpers +import { getIsWorkspaceCreationDisabled } from "@/plane-web/helpers/instance.helper"; // assets import CreateJoinWorkspaceDark from "@/public/onboarding/create-join-workspace-dark.webp"; import CreateJoinWorkspace from "@/public/onboarding/create-join-workspace-light.webp"; @@ -34,6 +37,8 @@ export const CreateOrJoinWorkspaces: React.FC = observer((props) => { const { data: user } = useUser(); // hooks const { resolvedTheme } = useTheme(); + // derived values + const isWorkspaceCreationEnabled = getIsWorkspaceCreationDisabled() === false; useEffect(() => { if (invitations.length > 0) { @@ -66,12 +71,25 @@ export const CreateOrJoinWorkspaces: React.FC = observer((props) => { handleCurrentViewChange={() => setCurrentView(ECreateOrJoinWorkspaceViews.WORKSPACE_CREATE)} /> ) : currentView === ECreateOrJoinWorkspaceViews.WORKSPACE_CREATE ? ( - setCurrentView(ECreateOrJoinWorkspaceViews.WORKSPACE_JOIN)} - /> + isWorkspaceCreationEnabled ? ( + setCurrentView(ECreateOrJoinWorkspaceViews.WORKSPACE_JOIN)} + /> + ) : ( +
+
+ + + You don't seem to have any invites to a workspace and your instance admin has restricted + creation of new workspaces. Please ask a workspace owner or admin to invite you to a workspace first + and come back to this screen to join. + +
+
+ ) ) : (
diff --git a/web/core/components/onboarding/create-workspace.tsx b/web/core/components/onboarding/create-workspace.tsx index d9d789ede16..56cbb14e372 100644 --- a/web/core/components/onboarding/create-workspace.tsx +++ b/web/core/components/onboarding/create-workspace.tsx @@ -3,13 +3,14 @@ import { useState } from "react"; import { observer } from "mobx-react"; import { Controller, useForm } from "react-hook-form"; +// constants +import { ORGANIZATION_SIZE, RESTRICTED_URLS } from "@plane/constants"; // types import { IUser, IWorkspace, TOnboardingSteps } from "@plane/types"; // ui import { Button, CustomSelect, Input, Spinner, TOAST_TYPE, setToast } from "@plane/ui"; // constants import { E_ONBOARDING, WORKSPACE_CREATED } from "@/constants/event-tracker"; -import { ORGANIZATION_SIZE, RESTRICTED_URLS } from "@/constants/workspace"; // hooks import { useEventTracker, useUserProfile, useUserSettings, useWorkspace } from "@/hooks/store"; // services @@ -154,18 +155,19 @@ export const CreateWorkspace: React.FC = observer((props) => { className="text-sm text-onboarding-text-300 font-medium after:content-['*'] after:ml-0.5 after:text-red-500" htmlFor="name" > - Workspace name + Name your workspace - /^[\w\s-]*$/.test(value) || `Name can only contain (" "), ( - ), ( _ ) & alphanumeric characters.`, + /^[\w\s-]*$/.test(value) || + `Workspaces names can contain only (" "), ( - ), ( _ ) and alphanumeric characters.`, maxLength: { value: 80, - message: "Workspace name should not exceed 80 characters", + message: "Limit your name to 80 characters.", }, }} render={({ field: { value, ref, onChange } }) => ( @@ -182,7 +184,7 @@ export const CreateWorkspace: React.FC = observer((props) => { shouldValidate: true, }); }} - placeholder="Enter workspace name..." + placeholder="Something familiar and recognizable is always best." ref={ref} hasError={Boolean(errors.name)} className="w-full border-onboarding-border-100 placeholder:text-custom-text-400" @@ -198,16 +200,16 @@ export const CreateWorkspace: React.FC = observer((props) => { className="text-sm text-onboarding-text-300 font-medium after:content-['*'] after:ml-0.5 after:text-red-500" htmlFor="slug" > - Workspace URL + Set your workspace's URL ( @@ -223,20 +225,22 @@ export const CreateWorkspace: React.FC = observer((props) => { type="text" value={value.toLocaleLowerCase().trim().replace(/ /g, "-")} onChange={(e) => { - /^[a-zA-Z0-9_-]+$/.test(e.target.value) ? setInvalidSlug(false) : setInvalidSlug(true); + if (/^[a-zA-Z0-9_-]+$/.test(e.target.value)) setInvalidSlug(false); + else setInvalidSlug(true); onChange(e.target.value.toLowerCase()); }} ref={ref} hasError={Boolean(errors.slug)} + placeholder="workspace-name" className="w-full border-none !px-0" />
)} />

You can only edit the slug of the URL

- {slugError &&

Workspace URL is already taken!

} + {slugError &&

This URL is taken. Try something else.

} {invalidSlug && ( -

{`URL can only contain ( - ), ( _ ) & alphanumeric characters.`}

+

{`URLs can contain only ( - ), ( _ ) and alphanumeric characters.`}

)} {errors.slug && {errors.slug.message}}
@@ -246,20 +250,20 @@ export const CreateWorkspace: React.FC = observer((props) => { className="text-sm text-onboarding-text-300 font-medium after:content-['*'] after:ml-0.5 after:text-red-500" htmlFor="organization_size" > - Company size + How many people will use this workspace?
( c === value) ?? ( - Select organization size + Select a range ) } buttonClassName="!border-[0.5px] !border-onboarding-border-100 !shadow-none !rounded-md" diff --git a/web/core/components/workspace/create-workspace-form.tsx b/web/core/components/workspace/create-workspace-form.tsx index f9234290d34..72568cd5d1d 100644 --- a/web/core/components/workspace/create-workspace-form.tsx +++ b/web/core/components/workspace/create-workspace-form.tsx @@ -3,13 +3,14 @@ import { Dispatch, SetStateAction, useEffect, useState, FC } from "react"; import { observer } from "mobx-react"; import { Controller, useForm } from "react-hook-form"; +// constants +import { ORGANIZATION_SIZE, RESTRICTED_URLS } from "@plane/constants"; // types import { IWorkspace } from "@plane/types"; // ui import { Button, CustomSelect, Input, TOAST_TYPE, setToast } from "@plane/ui"; // constants import { WORKSPACE_CREATED } from "@/constants/event-tracker"; -import { ORGANIZATION_SIZE, RESTRICTED_URLS } from "@/constants/workspace"; // hooks import { useEventTracker, useWorkspace } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; @@ -40,8 +41,8 @@ export const CreateWorkspaceForm: FC = observer((props) => { setDefaultValues, secondaryButton, primaryButtonText = { - loading: "Creating...", - default: "Create Workspace", + loading: "Creating workspace", + default: "Create workspace", }, } = props; // states @@ -124,7 +125,7 @@ export const CreateWorkspaceForm: FC = observer((props) => {
@@ -132,12 +133,13 @@ export const CreateWorkspaceForm: FC = observer((props) => { control={control} name="name" rules={{ - required: "Workspace name is required", + required: "This is a required field.", validate: (value) => - /^[\w\s-]*$/.test(value) || `Name can only contain (" "), ( - ), ( _ ) & alphanumeric characters.`, + /^[\w\s-]*$/.test(value) || + `Workspaces names can contain only (" "), ( - ), ( _ ) and alphanumeric characters.`, maxLength: { value: 80, - message: "Workspace name should not exceed 80 characters", + message: "Limit your name to 80 characters.", }, }} render={({ field: { value, ref, onChange } }) => ( @@ -154,7 +156,7 @@ export const CreateWorkspaceForm: FC = observer((props) => { }} ref={ref} hasError={Boolean(errors.name)} - placeholder="Enter workspace name..." + placeholder="Something familiar and recognizable is always best." className="w-full" /> )} @@ -164,7 +166,7 @@ export const CreateWorkspaceForm: FC = observer((props) => {
@@ -173,10 +175,10 @@ export const CreateWorkspaceForm: FC = observer((props) => { control={control} name="slug" rules={{ - required: "Workspace slug is required", + required: "This is a required field.", maxLength: { value: 48, - message: "Workspace slug should not exceed 48 characters", + message: "Limit your URL to 48 characters.", }, }} render={({ field: { onChange, value, ref } }) => ( @@ -185,12 +187,13 @@ export const CreateWorkspaceForm: FC = observer((props) => { type="text" value={value.toLocaleLowerCase().trim().replace(/ /g, "-")} onChange={(e) => { - /^[a-zA-Z0-9_-]+$/.test(e.target.value) ? setInvalidSlug(false) : setInvalidSlug(true); + if (/^[a-zA-Z0-9_-]+$/.test(e.target.value)) setInvalidSlug(false); + else setInvalidSlug(true); onChange(e.target.value.toLowerCase()); }} ref={ref} hasError={Boolean(errors.slug)} - placeholder="Enter workspace url..." + placeholder="workspace-name" className="block w-full rounded-md border-none bg-transparent !px-0 py-2 text-sm" /> )} @@ -198,26 +201,26 @@ export const CreateWorkspaceForm: FC = observer((props) => {
{slugError &&

Workspace URL is already taken!

} {invalidSlug && ( -

{`URL can only contain ( - ), ( _ ) & alphanumeric characters.`}

+

{`URLs can contain only ( - ), ( _ ) and alphanumeric characters.`}

)} {errors.slug && {errors.slug.message}}
- What size is your organization?* + How many people will use this workspace?*
( c === value) ?? ( - Select organization size + Select a range ) } buttonClassName="!border-[0.5px] !border-custom-border-200 !shadow-none" diff --git a/web/core/components/workspace/settings/workspace-details.tsx b/web/core/components/workspace/settings/workspace-details.tsx index dbcaf1ae9b0..8368705b5d5 100644 --- a/web/core/components/workspace/settings/workspace-details.tsx +++ b/web/core/components/workspace/settings/workspace-details.tsx @@ -4,6 +4,9 @@ import { useEffect, useState, FC } from "react"; import { observer } from "mobx-react"; import { Controller, useForm } from "react-hook-form"; import { Pencil } from "lucide-react"; +// constants +import { ORGANIZATION_SIZE } from "@plane/constants"; +// types import { IWorkspace } from "@plane/types"; // ui import { Button, CustomSelect, Input, TOAST_TYPE, setToast } from "@plane/ui"; @@ -12,7 +15,6 @@ import { LogoSpinner } from "@/components/common"; import { WorkspaceImageUploadModal } from "@/components/core"; // constants import { WORKSPACE_UPDATED } from "@/constants/event-tracker"; -import { ORGANIZATION_SIZE } from "@/constants/workspace"; // helpers import { getFileURL } from "@/helpers/file.helper"; import { copyUrlToClipboard } from "@/helpers/string.helper"; diff --git a/web/core/components/workspace/sidebar/dropdown.tsx b/web/core/components/workspace/sidebar/dropdown.tsx index a60a7d7e1b1..472f1b4efd6 100644 --- a/web/core/components/workspace/sidebar/dropdown.tsx +++ b/web/core/components/workspace/sidebar/dropdown.tsx @@ -20,6 +20,9 @@ import { getFileURL } from "@/helpers/file.helper"; import { useAppTheme, useUser, useUserPermissions, useUserProfile, useWorkspace } from "@/hooks/store"; // plane web constants import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; +// plane web helpers +import { getIsWorkspaceCreationDisabled } from "@/plane-web/helpers/instance.helper"; +// components import { WorkspaceLogo } from "../logo"; // Static Data @@ -53,6 +56,8 @@ export const SidebarDropdown = observer(() => { } = useUser(); const { updateUserProfile } = useUserProfile(); const { allowPermissions } = useUserPermissions(); + // derived values + const isWorkspaceCreationEnabled = getIsWorkspaceCreationDisabled() === false; const isUserInstanceAdmin = false; const { currentWorkspace: activeWorkspace, workspaces } = useWorkspace(); @@ -205,15 +210,17 @@ export const SidebarDropdown = observer(() => { )}
- - - - Create workspace - - + {isWorkspaceCreationEnabled && ( + + + + Create workspace + + + )} {userLinks(workspaceSlug?.toString() ?? "").map( (link, index) => allowPermissions(link.access, EUserPermissionsLevel.WORKSPACE) && ( diff --git a/web/core/constants/workspace.ts b/web/core/constants/workspace.ts index 405c680d725..3b523ffb4ed 100644 --- a/web/core/constants/workspace.ts +++ b/web/core/constants/workspace.ts @@ -29,8 +29,6 @@ export const ROLE_DETAILS = { }, }; -export const ORGANIZATION_SIZE = ["Just myself", "2-10", "11-50", "51-200", "201-500", "500+"]; - export const USER_ROLES = [ { value: "Product / Project Manager", label: "Product / Project Manager" }, { value: "Development / Engineering", label: "Development / Engineering" }, @@ -106,18 +104,3 @@ export const DEFAULT_GLOBAL_VIEWS_LIST: { label: "Subscribed", }, ]; - -export const RESTRICTED_URLS = [ - "404", - "accounts", - "api", - "create-workspace", - "error", - "god-mode", - "installations", - "invitations", - "onboarding", - "profile", - "spaces", - "workspace-invitations", -]; diff --git a/web/ee/helpers/instance.helper.ts b/web/ee/helpers/instance.helper.ts new file mode 100644 index 00000000000..ece25547ddd --- /dev/null +++ b/web/ee/helpers/instance.helper.ts @@ -0,0 +1 @@ +export * from "ce/helpers/instance.helper"; diff --git a/web/public/workspace/workspace-creation-disabled.png b/web/public/workspace/workspace-creation-disabled.png new file mode 100644 index 00000000000..09e05ef8388 Binary files /dev/null and b/web/public/workspace/workspace-creation-disabled.png differ