feat: workspace management from admin app (#6093)
* feat: workspace management from admin app * chore: UI and UX copy improvements * chore: ux copy improvements
This commit is contained in:
parent
9dbb2b26c3
commit
05d3e3ae45
53 changed files with 1153 additions and 122 deletions
|
|
@ -121,7 +121,12 @@ export const InstanceAIForm: FC<IInstanceAIForm> = (props) => {
|
||||||
|
|
||||||
<div className="relative inline-flex items-center gap-2 rounded border border-custom-primary-100/20 bg-custom-primary-100/10 px-4 py-2 text-xs text-custom-primary-200">
|
<div className="relative inline-flex items-center gap-2 rounded border border-custom-primary-100/20 bg-custom-primary-100/10 px-4 py-2 text-xs text-custom-primary-200">
|
||||||
<Lightbulb height="14" width="14" />
|
<Lightbulb height="14" width="14" />
|
||||||
<div>If you have a preferred AI models vendor, please get in touch with us.</div>
|
<div>
|
||||||
|
If you have a preferred AI models vendor, please get in{" "}
|
||||||
|
<a className="underline font-medium" href="https://plane.so/contact">
|
||||||
|
touch with us.
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -195,7 +195,7 @@ export const InstanceGithubConfigForm: FC<Props> = (props) => {
|
||||||
</Button>
|
</Button>
|
||||||
<Link
|
<Link
|
||||||
href="/authentication"
|
href="/authentication"
|
||||||
className={cn(getButtonStyling("link-neutral", "md"), "font-medium")}
|
className={cn(getButtonStyling("neutral-primary", "md"), "font-medium")}
|
||||||
onClick={handleGoBack}
|
onClick={handleGoBack}
|
||||||
>
|
>
|
||||||
Go back
|
Go back
|
||||||
|
|
|
||||||
|
|
@ -191,7 +191,7 @@ export const InstanceGitlabConfigForm: FC<Props> = (props) => {
|
||||||
</Button>
|
</Button>
|
||||||
<Link
|
<Link
|
||||||
href="/authentication"
|
href="/authentication"
|
||||||
className={cn(getButtonStyling("link-neutral", "md"), "font-medium")}
|
className={cn(getButtonStyling("neutral-primary", "md"), "font-medium")}
|
||||||
onClick={handleGoBack}
|
onClick={handleGoBack}
|
||||||
>
|
>
|
||||||
Go back
|
Go back
|
||||||
|
|
|
||||||
|
|
@ -192,7 +192,7 @@ export const InstanceGoogleConfigForm: FC<Props> = (props) => {
|
||||||
</Button>
|
</Button>
|
||||||
<Link
|
<Link
|
||||||
href="/authentication"
|
href="/authentication"
|
||||||
className={cn(getButtonStyling("link-neutral", "md"), "font-medium")}
|
className={cn(getButtonStyling("neutral-primary", "md"), "font-medium")}
|
||||||
onClick={handleGoBack}
|
onClick={handleGoBack}
|
||||||
>
|
>
|
||||||
Go back
|
Go back
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ const InstanceAuthenticationPage = observer(() => {
|
||||||
<div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
|
<div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
|
||||||
<div className="text-xl font-medium text-custom-text-100">Manage authentication modes for your instance</div>
|
<div className="text-xl font-medium text-custom-text-100">Manage authentication modes for your instance</div>
|
||||||
<div className="text-sm font-normal text-custom-text-300">
|
<div className="text-sm font-normal text-custom-text-300">
|
||||||
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.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
|
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
|
||||||
|
|
@ -80,9 +80,11 @@ const InstanceAuthenticationPage = observer(() => {
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
value={Boolean(parseInt(enableSignUpConfig))}
|
value={Boolean(parseInt(enableSignUpConfig))}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
Boolean(parseInt(enableSignUpConfig)) === true
|
if (Boolean(parseInt(enableSignUpConfig)) === true) {
|
||||||
? updateConfig("ENABLE_SIGNUP", "0")
|
updateConfig("ENABLE_SIGNUP", "0");
|
||||||
: updateConfig("ENABLE_SIGNUP", "1");
|
} else {
|
||||||
|
updateConfig("ENABLE_SIGNUP", "1");
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
|
|
@ -90,7 +92,7 @@ const InstanceAuthenticationPage = observer(() => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-medium pt-6">Authentication modes</div>
|
<div className="text-lg font-medium pt-6">Available authentication modes</div>
|
||||||
<AuthenticationModes disabled={isSubmitting} updateConfig={updateConfig} />
|
<AuthenticationModes disabled={isSubmitting} updateConfig={updateConfig} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ export const InstanceEmailForm: FC<IInstanceEmailForm> = (props) => {
|
||||||
{
|
{
|
||||||
key: "EMAIL_FROM",
|
key: "EMAIL_FROM",
|
||||||
type: "text",
|
type: "text",
|
||||||
label: "Sender email address",
|
label: "Sender's email address",
|
||||||
description:
|
description:
|
||||||
"This is the email address your users will see when getting emails from this instance. You will need to verify this address.",
|
"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",
|
placeholder: "no-reply@projectplane.so",
|
||||||
|
|
@ -174,12 +174,12 @@ export const InstanceEmailForm: FC<IInstanceEmailForm> = (props) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-6 my-6 pt-4 border-t border-custom-border-100">
|
<div className="flex flex-col gap-6 my-6 pt-4 border-t border-custom-border-100">
|
||||||
<div className="flex w-full max-w-md flex-col gap-y-10 px-1">
|
<div className="flex w-full max-w-xl flex-col gap-y-10 px-1">
|
||||||
<div className="mr-8 flex items-center gap-10 pt-4">
|
<div className="mr-8 flex items-center gap-10 pt-4">
|
||||||
<div className="grow">
|
<div className="grow">
|
||||||
<div className="text-sm font-medium text-custom-text-100">Authentication (optional)</div>
|
<div className="text-sm font-medium text-custom-text-100">Authentication</div>
|
||||||
<div className="text-xs font-normal text-custom-text-300">
|
<div className="text-xs font-normal text-custom-text-300">
|
||||||
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.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -117,17 +117,18 @@ export const GeneralConfigurationForm: FC<IGeneralConfigurationForm> = observer(
|
||||||
</div>
|
</div>
|
||||||
<div className="grow">
|
<div className="grow">
|
||||||
<div className="text-sm font-medium text-custom-text-100 leading-5">
|
<div className="text-sm font-medium text-custom-text-100 leading-5">
|
||||||
Allow Plane to collect anonymous usage events
|
Let Plane collect anonymous usage data
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs font-normal text-custom-text-300 leading-5">
|
<div className="text-xs font-normal text-custom-text-300 leading-5">
|
||||||
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{" "}
|
||||||
<a
|
<a
|
||||||
href="https://docs.plane.so/self-hosting/telemetry"
|
href="https://docs.plane.so/self-hosting/telemetry"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-custom-primary-100 hover:underline"
|
className="text-custom-primary-100 hover:underline"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
Know more.
|
our Telemetry Policy.
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -60,9 +60,9 @@ export const IntercomConfig: FC<TIntercomConfig> = observer((props) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grow">
|
<div className="grow">
|
||||||
<div className="text-sm font-medium text-custom-text-100 leading-5">Talk to Plane</div>
|
<div className="text-sm font-medium text-custom-text-100 leading-5">Chat with us</div>
|
||||||
<div className="text-xs font-normal text-custom-text-300 leading-5">
|
<div className="text-xs font-normal text-custom-text-300 leading-5">
|
||||||
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.
|
automatically.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
212
admin/app/workspace/create/form.tsx
Normal file
212
admin/app/workspace/create/form.tsx
Normal file
|
|
@ -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<Partial<IWorkspace>>({
|
||||||
|
name: "",
|
||||||
|
slug: "",
|
||||||
|
organization_size: "",
|
||||||
|
});
|
||||||
|
// store hooks
|
||||||
|
const { createWorkspace } = useWorkspace();
|
||||||
|
// form info
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
control,
|
||||||
|
setValue,
|
||||||
|
getValues,
|
||||||
|
formState: { errors, isSubmitting, isValid },
|
||||||
|
} = useForm<IWorkspace>({ 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 (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="grid-col grid w-full max-w-4xl grid-cols-1 items-start justify-between gap-x-10 gap-y-6 lg:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h4 className="text-sm text-custom-text-300">Name your workspace</h4>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="name"
|
||||||
|
rules={{
|
||||||
|
required: "This is a required field.",
|
||||||
|
validate: (value) =>
|
||||||
|
/^[\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 } }) => (
|
||||||
|
<Input
|
||||||
|
id="workspaceName"
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-red-500">{errors?.name?.message}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h4 className="text-sm text-custom-text-300">Set your workspace's URL</h4>
|
||||||
|
<div className="flex gap-0.5 w-full items-center rounded-md border-[0.5px] border-custom-border-200 px-3">
|
||||||
|
<span className="whitespace-nowrap text-sm text-custom-text-200">{WEB_BASE_URL}/</span>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="slug"
|
||||||
|
rules={{
|
||||||
|
required: "The URL is a required field.",
|
||||||
|
maxLength: {
|
||||||
|
value: 48,
|
||||||
|
message: "Limit your URL to 48 characters.",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
render={({ field: { onChange, value, ref } }) => (
|
||||||
|
<Input
|
||||||
|
id="workspaceUrl"
|
||||||
|
type="text"
|
||||||
|
value={value.toLocaleLowerCase().trim().replace(/ /g, "-")}
|
||||||
|
onChange={(e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{slugError && <p className="text-sm text-red-500">This URL is taken. Try something else.</p>}
|
||||||
|
{invalidSlug && (
|
||||||
|
<p className="text-sm text-red-500">{`URLs can contain only ( - ), ( _ ) and alphanumeric characters.`}</p>
|
||||||
|
)}
|
||||||
|
{errors.slug && <span className="text-xs text-red-500">{errors.slug.message}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h4 className="text-sm text-custom-text-300">How many people will use this workspace?</h4>
|
||||||
|
<div className="w-full">
|
||||||
|
<Controller
|
||||||
|
name="organization_size"
|
||||||
|
control={control}
|
||||||
|
rules={{ required: "This is a required field." }}
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<CustomSelect
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
label={
|
||||||
|
ORGANIZATION_SIZE.find((c) => c === value) ?? (
|
||||||
|
<span className="text-custom-text-400">Select a range</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
buttonClassName="!border-[0.5px] !border-custom-border-200 !shadow-none"
|
||||||
|
input
|
||||||
|
optionsClassName="w-full"
|
||||||
|
>
|
||||||
|
{ORGANIZATION_SIZE.map((item) => (
|
||||||
|
<CustomSelect.Option key={item} value={item}>
|
||||||
|
{item}
|
||||||
|
</CustomSelect.Option>
|
||||||
|
))}
|
||||||
|
</CustomSelect>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.organization_size && (
|
||||||
|
<span className="text-sm text-red-500">{errors.organization_size.message}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex max-w-4xl items-center py-1 gap-4">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSubmit(handleCreateWorkspace)}
|
||||||
|
disabled={!isValid}
|
||||||
|
loading={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Creating workspace" : "Create workspace"}
|
||||||
|
</Button>
|
||||||
|
<Link className={getButtonStyling("neutral-primary", "sm")} href="/workspace">
|
||||||
|
Go back
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
21
admin/app/workspace/create/page.tsx
Normal file
21
admin/app/workspace/create/page.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
// components
|
||||||
|
import { WorkspaceCreateForm } from "./form";
|
||||||
|
|
||||||
|
const WorkspaceCreatePage = observer(() => (
|
||||||
|
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
|
||||||
|
<div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
|
||||||
|
<div className="text-xl font-medium text-custom-text-100">Create a new workspace on this instance.</div>
|
||||||
|
<div className="text-sm font-normal text-custom-text-300">
|
||||||
|
You will need to invite users from Workspace Settings after you create this workspace.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
|
||||||
|
<WorkspaceCreateForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
|
||||||
|
export default WorkspaceCreatePage;
|
||||||
12
admin/app/workspace/layout.tsx
Normal file
12
admin/app/workspace/layout.tsx
Normal file
|
|
@ -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 <AdminLayout>{children}</AdminLayout>;
|
||||||
|
}
|
||||||
169
admin/app/workspace/page.tsx
Normal file
169
admin/app/workspace/page.tsx
Normal file
|
|
@ -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<boolean>(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 (
|
||||||
|
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between gap-4 border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="text-xl font-medium text-custom-text-100">Workspaces on this instance</div>
|
||||||
|
<div className="text-sm font-normal text-custom-text-300">
|
||||||
|
See all workspaces and control who can create them.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{formattedConfig ? (
|
||||||
|
<div className={cn("w-full flex items-center gap-14 rounded")}>
|
||||||
|
<div className="flex grow items-center gap-4">
|
||||||
|
<div className="grow">
|
||||||
|
<div className="text-lg font-medium pb-1">Prevent anyone else from creating a workspace.</div>
|
||||||
|
<div className={cn("font-normal leading-5 text-custom-text-300 text-xs")}>
|
||||||
|
Toggling this on will let only you create workspaces. You will have to invite users to new
|
||||||
|
workspaces.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`shrink-0 pr-4 ${isSubmitting && "opacity-70"}`}>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<ToggleSwitch
|
||||||
|
value={Boolean(parseInt(disableWorkspaceCreation))}
|
||||||
|
onChange={() => {
|
||||||
|
if (Boolean(parseInt(disableWorkspaceCreation)) === true) {
|
||||||
|
updateConfig("DISABLE_WORKSPACE_CREATION", "0");
|
||||||
|
} else {
|
||||||
|
updateConfig("DISABLE_WORKSPACE_CREATION", "1");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Loader>
|
||||||
|
<Loader.Item height="50px" width="100%" />
|
||||||
|
</Loader>
|
||||||
|
)}
|
||||||
|
{workspaceLoader !== "init-loader" ? (
|
||||||
|
<>
|
||||||
|
<div className="pt-6 flex items-center justify-between gap-2">
|
||||||
|
<div className="flex flex-col items-start gap-x-2">
|
||||||
|
<div className="flex items-center gap-2 text-lg font-medium">
|
||||||
|
All workspaces on this instance{" "}
|
||||||
|
<span className="text-custom-text-300">• {workspaceIds.length}</span>
|
||||||
|
{workspaceLoader && ["mutation", "pagination"].includes(workspaceLoader) && (
|
||||||
|
<LoaderIcon className="w-4 h-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={cn("font-normal leading-5 text-custom-text-300 text-xs")}>
|
||||||
|
You can't yet delete workspaces and you can only go to the workspace if you are an Admin or a
|
||||||
|
Member.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link href="/workspace/create" className={getButtonStyling("primary", "sm")}>
|
||||||
|
Create workspace
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4 py-2">
|
||||||
|
{workspaceIds.map((workspaceId) => (
|
||||||
|
<WorkspaceListItem key={workspaceId} workspaceId={workspaceId} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{hasNextPage && (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button
|
||||||
|
variant="link-primary"
|
||||||
|
onClick={() => fetchNextWorkspaces()}
|
||||||
|
disabled={workspaceLoader === "pagination"}
|
||||||
|
>
|
||||||
|
Load more
|
||||||
|
{workspaceLoader === "pagination" && <LoaderIcon className="w-3 h-3 animate-spin" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Loader className="space-y-10 py-8">
|
||||||
|
<Loader.Item height="24px" width="20%" />
|
||||||
|
<Loader.Item height="92px" width="100%" />
|
||||||
|
<Loader.Item height="92px" width="100%" />
|
||||||
|
<Loader.Item height="92px" width="100%" />
|
||||||
|
</Loader>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default WorkspaceManagementPage;
|
||||||
|
|
@ -9,8 +9,8 @@ import { getButtonStyling } from "@plane/ui";
|
||||||
import { cn } from "@/helpers/common.helper";
|
import { cn } from "@/helpers/common.helper";
|
||||||
|
|
||||||
export const UpgradeButton: React.FC = () => (
|
export const UpgradeButton: React.FC = () => (
|
||||||
<a href="https://plane.so/one" target="_blank" className={cn(getButtonStyling("primary", "sm"))}>
|
<a href="https://plane.so/pricing?mode=self-hosted" target="_blank" className={cn(getButtonStyling("primary", "sm"))}>
|
||||||
Available on One
|
Upgrade
|
||||||
<SquareArrowOutUpRight className="h-3.5 w-3.5 p-0.5" />
|
<SquareArrowOutUpRight className="h-3.5 w-3.5 p-0.5" />
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -52,13 +52,13 @@ export const HelpSection: FC = observer(() => {
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={`flex items-center gap-1 ${isSidebarCollapsed ? "flex-col justify-center" : "w-full"}`}>
|
<div className={`flex items-center gap-1 ${isSidebarCollapsed ? "flex-col justify-center" : "w-full"}`}>
|
||||||
<Tooltip tooltipContent="Redirect to plane" position="right" className="ml-4" disabled={!isSidebarCollapsed}>
|
<Tooltip tooltipContent="Redirect to Plane" position="right" className="ml-4" disabled={!isSidebarCollapsed}>
|
||||||
<a
|
<a
|
||||||
href={redirectionLink}
|
href={redirectionLink}
|
||||||
className={`relative px-2 py-1.5 flex items-center gap-2 font-medium rounded border border-custom-primary-100/20 bg-custom-primary-100/10 text-xs text-custom-primary-200 whitespace-nowrap`}
|
className={`relative px-2 py-1.5 flex items-center gap-2 font-medium rounded border border-custom-primary-100/20 bg-custom-primary-100/10 text-xs text-custom-primary-200 whitespace-nowrap`}
|
||||||
>
|
>
|
||||||
<ExternalLink size={14} />
|
<ExternalLink size={14} />
|
||||||
{!isSidebarCollapsed && "Redirect to plane"}
|
{!isSidebarCollapsed && "Redirect to Plane"}
|
||||||
</a>
|
</a>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip tooltipContent="Help" position={isSidebarCollapsed ? "right" : "top"} className="ml-4">
|
<Tooltip tooltipContent="Help" position={isSidebarCollapsed ? "right" : "top"} className="ml-4">
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { observer } from "mobx-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react";
|
import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react";
|
||||||
import { Tooltip } from "@plane/ui";
|
import { Tooltip, WorkspaceIcon } from "@plane/ui";
|
||||||
// hooks
|
// hooks
|
||||||
import { cn } from "@/helpers/common.helper";
|
import { cn } from "@/helpers/common.helper";
|
||||||
import { useTheme } from "@/hooks/store";
|
import { useTheme } from "@/hooks/store";
|
||||||
|
|
@ -14,31 +14,37 @@ const INSTANCE_ADMIN_LINKS = [
|
||||||
{
|
{
|
||||||
Icon: Cog,
|
Icon: Cog,
|
||||||
name: "General",
|
name: "General",
|
||||||
description: "Identify your instances and get key details",
|
description: "Identify your instances and get key details.",
|
||||||
href: `/general/`,
|
href: `/general/`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Icon: WorkspaceIcon,
|
||||||
|
name: "Workspaces",
|
||||||
|
description: "Manage all workspaces on this instance.",
|
||||||
|
href: `/workspace/`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Icon: Mail,
|
Icon: Mail,
|
||||||
name: "Email",
|
name: "Email",
|
||||||
description: "Set up emails to your users",
|
description: "Configure your SMTP controls.",
|
||||||
href: `/email/`,
|
href: `/email/`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Icon: Lock,
|
Icon: Lock,
|
||||||
name: "Authentication",
|
name: "Authentication",
|
||||||
description: "Configure authentication modes",
|
description: "Configure authentication modes.",
|
||||||
href: `/authentication/`,
|
href: `/authentication/`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Icon: BrainCog,
|
Icon: BrainCog,
|
||||||
name: "Artificial intelligence",
|
name: "Artificial intelligence",
|
||||||
description: "Configure your OpenAI creds",
|
description: "Configure your OpenAI creds.",
|
||||||
href: `/ai/`,
|
href: `/ai/`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Icon: Image,
|
Icon: Image,
|
||||||
name: "Images in Plane",
|
name: "Images in Plane",
|
||||||
description: "Allow third-party image libraries",
|
description: "Allow third-party image libraries.",
|
||||||
href: `/image/`,
|
href: `/image/`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,10 @@ export const InstanceHeader: FC = observer(() => {
|
||||||
return "Github";
|
return "Github";
|
||||||
case "gitlab":
|
case "gitlab":
|
||||||
return "GitLab";
|
return "GitLab";
|
||||||
|
case "workspace":
|
||||||
|
return "Workspace";
|
||||||
|
case "create":
|
||||||
|
return "Create";
|
||||||
default:
|
default:
|
||||||
return pathName.toUpperCase();
|
return pathName.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { resolveGeneralTheme } from "helpers/common.helper";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
import { useTheme as nextUseTheme } from "next-themes";
|
import { useTheme as nextUseTheme } from "next-themes";
|
||||||
// ui
|
// ui
|
||||||
import { Button, getButtonStyling } from "@plane/ui";
|
import { Button, getButtonStyling } from "@plane/ui";
|
||||||
// helpers
|
|
||||||
import { WEB_BASE_URL, resolveGeneralTheme } from "helpers/common.helper";
|
|
||||||
// hooks
|
// hooks
|
||||||
import { useTheme } from "@/hooks/store";
|
import { useTheme } from "@/hooks/store";
|
||||||
// icons
|
// icons
|
||||||
|
|
@ -20,8 +20,6 @@ export const NewUserPopup: React.FC = observer(() => {
|
||||||
// theme
|
// theme
|
||||||
const { resolvedTheme } = nextUseTheme();
|
const { resolvedTheme } = nextUseTheme();
|
||||||
|
|
||||||
const redirectionLink = encodeURI(WEB_BASE_URL + "/create-workspace");
|
|
||||||
|
|
||||||
if (!isNewUserPopup) return <></>;
|
if (!isNewUserPopup) return <></>;
|
||||||
return (
|
return (
|
||||||
<div className="absolute bottom-8 right-8 p-6 w-96 border border-custom-border-100 shadow-md rounded-lg bg-custom-background-100">
|
<div className="absolute bottom-8 right-8 p-6 w-96 border border-custom-border-100 shadow-md rounded-lg bg-custom-background-100">
|
||||||
|
|
@ -30,12 +28,12 @@ export const NewUserPopup: React.FC = observer(() => {
|
||||||
<div className="text-base font-semibold">Create workspace</div>
|
<div className="text-base font-semibold">Create workspace</div>
|
||||||
<div className="py-2 text-sm font-medium text-custom-text-300">
|
<div className="py-2 text-sm font-medium text-custom-text-300">
|
||||||
Instance setup done! Welcome to Plane instance portal. Start your journey with by creating your first
|
Instance setup done! Welcome to Plane instance portal. Start your journey with by creating your first
|
||||||
workspace, you will need to login again.
|
workspace.
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 pt-2">
|
<div className="flex items-center gap-4 pt-2">
|
||||||
<a href={redirectionLink} className={getButtonStyling("primary", "sm")}>
|
<Link href="/workspace/create" className={getButtonStyling("primary", "sm")}>
|
||||||
Create workspace
|
Create workspace
|
||||||
</a>
|
</Link>
|
||||||
<Button variant="neutral-primary" size="sm" onClick={toggleNewUserPopup}>
|
<Button variant="neutral-primary" size="sm" onClick={toggleNewUserPopup}>
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
1
admin/core/components/workspace/index.ts
Normal file
1
admin/core/components/workspace/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./list-item";
|
||||||
82
admin/core/components/workspace/list-item.tsx
Normal file
82
admin/core/components/workspace/list-item.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<Link
|
||||||
|
key={workspaceId}
|
||||||
|
href={encodeURI(WEB_BASE_URL + "/" + workspace.slug)}
|
||||||
|
target="_blank"
|
||||||
|
className="group flex items-center justify-between p-4 gap-2.5 truncate border border-custom-border-200/70 hover:border-custom-border-200 hover:bg-custom-background-90 rounded-md"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<span
|
||||||
|
className={`relative flex h-8 w-8 flex-shrink-0 items-center justify-center p-2 mt-1 text-xs uppercase ${
|
||||||
|
!workspace?.logo_url && "rounded bg-custom-primary-500 text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{workspace?.logo_url && workspace.logo_url !== "" ? (
|
||||||
|
<img
|
||||||
|
src={getFileURL(workspace.logo_url)}
|
||||||
|
className="absolute left-0 top-0 h-full w-full rounded object-cover"
|
||||||
|
alt="Workspace Logo"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
(workspace?.name?.[0] ?? "...")
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-col items-start gap-1">
|
||||||
|
<div className="flex flex-wrap w-full items-center gap-2.5">
|
||||||
|
<h3 className={`text-base font-medium capitalize`}>{workspace.name}</h3>/
|
||||||
|
<Tooltip tooltipContent="The unique URL of your workspace">
|
||||||
|
<h4 className="text-sm text-custom-text-300">[{workspace.slug}]</h4>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
{workspace.owner.email && (
|
||||||
|
<div className="flex items-center gap-1 text-xs">
|
||||||
|
<h3 className="text-custom-text-200 font-medium">Owned by:</h3>
|
||||||
|
<h4 className="text-custom-text-300">{workspace.owner.email}</h4>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2.5 text-xs">
|
||||||
|
{workspace.total_projects !== null && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<h3 className="text-custom-text-200 font-medium">Total projects:</h3>
|
||||||
|
<h4 className="text-custom-text-300">{workspace.total_projects}</h4>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{workspace.total_members !== null && (
|
||||||
|
<>
|
||||||
|
•
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<h3 className="text-custom-text-200 font-medium">Total members:</h3>
|
||||||
|
<h4 className="text-custom-text-300">{workspace.total_members}</h4>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<ExternalLink size={14} className="text-custom-text-400 group-hover:text-custom-text-200" />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
export * from "./use-theme";
|
export * from "./use-theme";
|
||||||
export * from "./use-instance";
|
export * from "./use-instance";
|
||||||
export * from "./use-user";
|
export * from "./use-user";
|
||||||
|
export * from "./use-workspace";
|
||||||
|
|
|
||||||
10
admin/core/hooks/store/use-workspace.tsx
Normal file
10
admin/core/hooks/store/use-workspace.tsx
Normal file
|
|
@ -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;
|
||||||
|
};
|
||||||
52
admin/core/services/workspace.service.ts
Normal file
52
admin/core/services/workspace.service.ts
Normal file
|
|
@ -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<TWorkspacePaginationInfo>
|
||||||
|
*/
|
||||||
|
async getWorkspaces(nextPageCursor?: string): Promise<TWorkspacePaginationInfo> {
|
||||||
|
return this.get<TWorkspacePaginationInfo>("/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<any>
|
||||||
|
*/
|
||||||
|
async workspaceSlugCheck(slug: string): Promise<any> {
|
||||||
|
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<IWorkspace>
|
||||||
|
*/
|
||||||
|
async createWorkspace(data: IWorkspace): Promise<IWorkspace> {
|
||||||
|
return this.post<IWorkspace, IWorkspace>("/api/instances/workspaces/", data)
|
||||||
|
.then((response) => response.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { enableStaticRendering } from "mobx-react";
|
||||||
import { IInstanceStore, InstanceStore } from "./instance.store";
|
import { IInstanceStore, InstanceStore } from "./instance.store";
|
||||||
import { IThemeStore, ThemeStore } from "./theme.store";
|
import { IThemeStore, ThemeStore } from "./theme.store";
|
||||||
import { IUserStore, UserStore } from "./user.store";
|
import { IUserStore, UserStore } from "./user.store";
|
||||||
|
import { IWorkspaceStore, WorkspaceStore } from "./workspace.store";
|
||||||
|
|
||||||
enableStaticRendering(typeof window === "undefined");
|
enableStaticRendering(typeof window === "undefined");
|
||||||
|
|
||||||
|
|
@ -10,17 +11,20 @@ export abstract class CoreRootStore {
|
||||||
theme: IThemeStore;
|
theme: IThemeStore;
|
||||||
instance: IInstanceStore;
|
instance: IInstanceStore;
|
||||||
user: IUserStore;
|
user: IUserStore;
|
||||||
|
workspace: IWorkspaceStore;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.theme = new ThemeStore(this);
|
this.theme = new ThemeStore(this);
|
||||||
this.instance = new InstanceStore(this);
|
this.instance = new InstanceStore(this);
|
||||||
this.user = new UserStore(this);
|
this.user = new UserStore(this);
|
||||||
|
this.workspace = new WorkspaceStore(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
hydrate(initialData: any) {
|
hydrate(initialData: any) {
|
||||||
this.theme.hydrate(initialData.theme);
|
this.theme.hydrate(initialData.theme);
|
||||||
this.instance.hydrate(initialData.instance);
|
this.instance.hydrate(initialData.instance);
|
||||||
this.user.hydrate(initialData.user);
|
this.user.hydrate(initialData.user);
|
||||||
|
this.workspace.hydrate(initialData.workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
resetOnSignOut() {
|
resetOnSignOut() {
|
||||||
|
|
@ -28,5 +32,6 @@ export abstract class CoreRootStore {
|
||||||
this.instance = new InstanceStore(this);
|
this.instance = new InstanceStore(this);
|
||||||
this.user = new UserStore(this);
|
this.user = new UserStore(this);
|
||||||
this.theme = new ThemeStore(this);
|
this.theme = new ThemeStore(this);
|
||||||
|
this.workspace = new WorkspaceStore(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
150
admin/core/store/workspace.store.ts
Normal file
150
admin/core/store/workspace.store.ts
Normal file
|
|
@ -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<string, IWorkspace>;
|
||||||
|
paginationInfo: TPaginationInfo | undefined;
|
||||||
|
// computed
|
||||||
|
workspaceIds: string[];
|
||||||
|
// helper actions
|
||||||
|
hydrate: (data: any) => void;
|
||||||
|
getWorkspaceById: (workspaceId: string) => IWorkspace | undefined;
|
||||||
|
// fetch actions
|
||||||
|
fetchWorkspaces: () => Promise<IWorkspace[]>;
|
||||||
|
fetchNextWorkspaces: () => Promise<IWorkspace[]>;
|
||||||
|
// curd actions
|
||||||
|
createWorkspace: (data: IWorkspace) => Promise<IWorkspace>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WorkspaceStore implements IWorkspaceStore {
|
||||||
|
// observables
|
||||||
|
loader: TLoader = "init-loader";
|
||||||
|
workspaces: Record<string, IWorkspace> = {};
|
||||||
|
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<IWorkspace[]> => {
|
||||||
|
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<IWorkspace[]>
|
||||||
|
*/
|
||||||
|
fetchNextWorkspaces = async (): Promise<IWorkspace[]> => {
|
||||||
|
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<IWorkspace>
|
||||||
|
*/
|
||||||
|
createWorkspace = async (data: IWorkspace): Promise<IWorkspace> => {
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
# Python imports
|
# Python imports
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
|
import os
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
@ -38,7 +39,7 @@ from django.utils.decorators import method_decorator
|
||||||
from django.views.decorators.cache import cache_control
|
from django.views.decorators.cache import cache_control
|
||||||
from django.views.decorators.vary import vary_on_cookie
|
from django.views.decorators.vary import vary_on_cookie
|
||||||
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
|
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
|
||||||
|
from plane.license.utils.instance_value import get_configuration_value
|
||||||
|
|
||||||
class WorkSpaceViewSet(BaseViewSet):
|
class WorkSpaceViewSet(BaseViewSet):
|
||||||
model = Workspace
|
model = Workspace
|
||||||
|
|
@ -80,6 +81,21 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||||
|
|
||||||
def create(self, request):
|
def create(self, request):
|
||||||
try:
|
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)
|
serializer = WorkSpaceSerializer(data=request.data)
|
||||||
|
|
||||||
slug = request.data.get("slug", False)
|
slug = request.data.get("slug", False)
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,4 @@ from .instance import InstanceSerializer
|
||||||
|
|
||||||
from .configuration import InstanceConfigurationSerializer
|
from .configuration import InstanceConfigurationSerializer
|
||||||
from .admin import InstanceAdminSerializer, InstanceAdminMeSerializer
|
from .admin import InstanceAdminSerializer, InstanceAdminMeSerializer
|
||||||
|
from .workspace import WorkspaceSerializer
|
||||||
6
apiserver/plane/license/api/serializers/user.py
Normal file
6
apiserver/plane/license/api/serializers/user.py
Normal file
|
|
@ -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",]
|
||||||
34
apiserver/plane/license/api/serializers/workspace.py
Normal file
34
apiserver/plane/license/api/serializers/workspace.py
Normal file
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -14,3 +14,5 @@ from .admin import (
|
||||||
)
|
)
|
||||||
|
|
||||||
from .changelog import ChangeLogEndpoint
|
from .changelog import ChangeLogEndpoint
|
||||||
|
|
||||||
|
from .workspace import InstanceWorkSpaceAvailabilityCheckEndpoint, InstanceWorkSpaceEndpoint
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ class InstanceEndpoint(BaseAPIView):
|
||||||
# Get all the configuration
|
# Get all the configuration
|
||||||
(
|
(
|
||||||
ENABLE_SIGNUP,
|
ENABLE_SIGNUP,
|
||||||
|
DISABLE_WORKSPACE_CREATION,
|
||||||
IS_GOOGLE_ENABLED,
|
IS_GOOGLE_ENABLED,
|
||||||
IS_GITHUB_ENABLED,
|
IS_GITHUB_ENABLED,
|
||||||
GITHUB_APP_NAME,
|
GITHUB_APP_NAME,
|
||||||
|
|
@ -65,6 +66,10 @@ class InstanceEndpoint(BaseAPIView):
|
||||||
"key": "ENABLE_SIGNUP",
|
"key": "ENABLE_SIGNUP",
|
||||||
"default": os.environ.get("ENABLE_SIGNUP", "0"),
|
"default": os.environ.get("ENABLE_SIGNUP", "0"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"key": "DISABLE_WORKSPACE_CREATION",
|
||||||
|
"default": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"key": "IS_GOOGLE_ENABLED",
|
"key": "IS_GOOGLE_ENABLED",
|
||||||
"default": os.environ.get("IS_GOOGLE_ENABLED", "0"),
|
"default": os.environ.get("IS_GOOGLE_ENABLED", "0"),
|
||||||
|
|
@ -125,6 +130,7 @@ class InstanceEndpoint(BaseAPIView):
|
||||||
data = {}
|
data = {}
|
||||||
# Authentication
|
# Authentication
|
||||||
data["enable_signup"] = ENABLE_SIGNUP == "1"
|
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_google_enabled"] = IS_GOOGLE_ENABLED == "1"
|
||||||
data["is_github_enabled"] = IS_GITHUB_ENABLED == "1"
|
data["is_github_enabled"] = IS_GITHUB_ENABLED == "1"
|
||||||
data["is_gitlab_enabled"] = IS_GITLAB_ENABLED == "1"
|
data["is_gitlab_enabled"] = IS_GITLAB_ENABLED == "1"
|
||||||
|
|
|
||||||
115
apiserver/plane/license/api/views/workspace.py
Normal file
115
apiserver/plane/license/api/views/workspace.py
Normal file
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -29,6 +29,12 @@ class Command(BaseCommand):
|
||||||
"category": "AUTHENTICATION",
|
"category": "AUTHENTICATION",
|
||||||
"is_encrypted": False,
|
"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",
|
"key": "ENABLE_EMAIL_PASSWORD",
|
||||||
"value": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"),
|
"value": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"),
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ from plane.license.api.views import (
|
||||||
InstanceAdminSignOutEndpoint,
|
InstanceAdminSignOutEndpoint,
|
||||||
InstanceAdminUserSessionEndpoint,
|
InstanceAdminUserSessionEndpoint,
|
||||||
ChangeLogEndpoint,
|
ChangeLogEndpoint,
|
||||||
|
InstanceWorkSpaceAvailabilityCheckEndpoint,
|
||||||
|
InstanceWorkSpaceEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
|
@ -55,4 +57,14 @@ urlpatterns = [
|
||||||
EmailCredentialCheckEndpoint.as_view(),
|
EmailCredentialCheckEndpoint.as_view(),
|
||||||
name="email-credential-check",
|
name="email-credential-check",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"workspace-slug-check/",
|
||||||
|
InstanceWorkSpaceAvailabilityCheckEndpoint.as_view(),
|
||||||
|
name="instance-workspace-availability",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/",
|
||||||
|
InstanceWorkSpaceEndpoint.as_view(),
|
||||||
|
name="instance-workspace",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
export * from "./auth";
|
export * from "./auth";
|
||||||
export * from "./issue";
|
export * from "./issue";
|
||||||
|
export * from "./workspace";
|
||||||
|
|
|
||||||
23
packages/constants/workspace.ts
Normal file
23
packages/constants/workspace.ts
Normal file
|
|
@ -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",
|
||||||
|
];
|
||||||
5
packages/types/src/instance/base.d.ts
vendored
5
packages/types/src/instance/base.d.ts
vendored
|
|
@ -4,6 +4,7 @@ import {
|
||||||
TInstanceEmailConfigurationKeys,
|
TInstanceEmailConfigurationKeys,
|
||||||
TInstanceImageConfigurationKeys,
|
TInstanceImageConfigurationKeys,
|
||||||
TInstanceAuthenticationKeys,
|
TInstanceAuthenticationKeys,
|
||||||
|
TInstanceWorkspaceConfigurationKeys,
|
||||||
} from "./";
|
} from "./";
|
||||||
|
|
||||||
export interface IInstanceInfo {
|
export interface IInstanceInfo {
|
||||||
|
|
@ -36,6 +37,7 @@ export interface IInstance {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IInstanceConfig {
|
export interface IInstanceConfig {
|
||||||
|
is_workspace_creation_disabled: boolean;
|
||||||
is_google_enabled: boolean;
|
is_google_enabled: boolean;
|
||||||
is_github_enabled: boolean;
|
is_github_enabled: boolean;
|
||||||
is_gitlab_enabled: boolean;
|
is_gitlab_enabled: boolean;
|
||||||
|
|
@ -78,7 +80,8 @@ export type TInstanceConfigurationKeys =
|
||||||
| TInstanceEmailConfigurationKeys
|
| TInstanceEmailConfigurationKeys
|
||||||
| TInstanceImageConfigurationKeys
|
| TInstanceImageConfigurationKeys
|
||||||
| TInstanceAuthenticationKeys
|
| TInstanceAuthenticationKeys
|
||||||
| TInstanceIntercomConfigurationKeys;
|
| TInstanceIntercomConfigurationKeys
|
||||||
|
| TInstanceWorkspaceConfigurationKeys;
|
||||||
|
|
||||||
export interface IInstanceConfiguration {
|
export interface IInstanceConfiguration {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
1
packages/types/src/instance/index.d.ts
vendored
1
packages/types/src/instance/index.d.ts
vendored
|
|
@ -3,3 +3,4 @@ export * from "./auth";
|
||||||
export * from "./base";
|
export * from "./base";
|
||||||
export * from "./email";
|
export * from "./email";
|
||||||
export * from "./image";
|
export * from "./image";
|
||||||
|
export * from "./workspace";
|
||||||
|
|
|
||||||
1
packages/types/src/instance/workspace.d.ts
vendored
Normal file
1
packages/types/src/instance/workspace.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export type TInstanceWorkspaceConfigurationKeys = "DISABLE_WORKSPACE_CREATION";
|
||||||
5
packages/types/src/workspace.d.ts
vendored
5
packages/types/src/workspace.d.ts
vendored
|
|
@ -21,6 +21,7 @@ export interface IWorkspace {
|
||||||
readonly updated_by: string;
|
readonly updated_by: string;
|
||||||
organization_size: string;
|
organization_size: string;
|
||||||
total_issues: number;
|
total_issues: number;
|
||||||
|
total_projects?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IWorkspaceLite {
|
export interface IWorkspaceLite {
|
||||||
|
|
@ -222,3 +223,7 @@ export interface IWorkspaceProgressResponse {
|
||||||
export interface IWorkspaceAnalyticsResponse {
|
export interface IWorkspaceAnalyticsResponse {
|
||||||
completion_chart: any;
|
completion_chart: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TWorkspacePaginationInfo = TPaginationInfo & {
|
||||||
|
results: IWorkspace[];
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -33,3 +33,4 @@ export * from "./in-progress-icon";
|
||||||
export * from "./done-icon";
|
export * from "./done-icon";
|
||||||
export * from "./pending-icon";
|
export * from "./pending-icon";
|
||||||
export * from "./pi-chat";
|
export * from "./pi-chat";
|
||||||
|
export * from "./workspace-icon";
|
||||||
|
|
|
||||||
14
packages/ui/src/icons/workspace-icon.tsx
Normal file
14
packages/ui/src/icons/workspace-icon.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { ISvgIcons } from "./type";
|
||||||
|
|
||||||
|
export const WorkspaceIcon: React.FC<ISvgIcons> = ({ className }) => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M2.25 3C2.25 2.58579 2.58579 2.25 3 2.25H15C15.4142 2.25 15.75 2.58579 15.75 3C15.75 3.41421 15.4142 3.75 15 3.75V6.75H21C21.4142 6.75 21.75 7.08579 21.75 7.5C21.75 7.91421 21.4142 8.25 21 8.25V20.25H21.75C22.1642 20.25 22.5 20.5858 22.5 21C22.5 21.4142 22.1642 21.75 21.75 21.75H2.25C1.83579 21.75 1.5 21.4142 1.5 21C1.5 20.5858 1.83579 20.25 2.25 20.25H3V3.75C2.58579 3.75 2.25 3.41421 2.25 3ZM4.5 3.75V20.25H6V17.625C6 16.5898 6.83979 15.75 7.875 15.75H10.125C11.1602 15.75 12 16.5898 12 17.625V20.25H13.5V3.75H4.5ZM15 8.25V20.25H19.5V8.25H15ZM10.5 20.25V17.625C10.5 17.4182 10.3318 17.25 10.125 17.25H7.875C7.66821 17.25 7.5 17.4182 7.5 17.625V20.25H10.5ZM6 6.75C6 6.33579 6.33579 6 6.75 6H7.5C7.91421 6 8.25 6.33579 8.25 6.75C8.25 7.16421 7.91421 7.5 7.5 7.5H6.75C6.33579 7.5 6 7.16421 6 6.75ZM9.75 6.75C9.75 6.33579 10.0858 6 10.5 6H11.25C11.6642 6 12 6.33579 12 6.75C12 7.16421 11.6642 7.5 11.25 7.5H10.5C10.0858 7.5 9.75 7.16421 9.75 6.75ZM6 9.75C6 9.33579 6.33579 9 6.75 9H7.5C7.91421 9 8.25 9.33579 8.25 9.75C8.25 10.1642 7.91421 10.5 7.5 10.5H6.75C6.33579 10.5 6 10.1642 6 9.75ZM9.75 9.75C9.75 9.33579 10.0858 9 10.5 9H11.25C11.6642 9 12 9.33579 12 9.75C12 10.1642 11.6642 10.5 11.25 10.5H10.5C10.0858 10.5 9.75 10.1642 9.75 9.75ZM16.5 11.25C16.5 10.8358 16.8358 10.5 17.25 10.5H17.258C17.6722 10.5 18.008 10.8358 18.008 11.25V11.258C18.008 11.6722 17.6722 12.008 17.258 12.008H17.25C16.8358 12.008 16.5 11.6722 16.5 11.258V11.25ZM6 12.75C6 12.3358 6.33579 12 6.75 12H7.5C7.91421 12 8.25 12.3358 8.25 12.75C8.25 13.1642 7.91421 13.5 7.5 13.5H6.75C6.33579 13.5 6 13.1642 6 12.75ZM9.75 12.75C9.75 12.3358 10.0858 12 10.5 12H11.25C11.6642 12 12 12.3358 12 12.75C12 13.1642 11.6642 13.5 11.25 13.5H10.5C10.0858 13.5 9.75 13.1642 9.75 12.75ZM16.5 14.25C16.5 13.8358 16.8358 13.5 17.25 13.5H17.258C17.6722 13.5 18.008 13.8358 18.008 14.25V14.258C18.008 14.6722 17.6722 15.008 17.258 15.008H17.25C16.8358 15.008 16.5 14.6722 16.5 14.258V14.25ZM16.5 17.25C16.5 16.8358 16.8358 16.5 17.25 16.5H17.258C17.6722 16.5 18.008 16.8358 18.008 17.25V17.258C18.008 17.6722 17.6722 18.008 17.258 18.008H17.25C16.8358 18.008 16.5 17.6722 16.5 17.258V17.25Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
@ -7,15 +7,19 @@ import Link from "next/link";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { IWorkspace } from "@plane/types";
|
import { IWorkspace } from "@plane/types";
|
||||||
// components
|
// components
|
||||||
|
import { Button, getButtonStyling } from "@plane/ui";
|
||||||
import { CreateWorkspaceForm } from "@/components/workspace";
|
import { CreateWorkspaceForm } from "@/components/workspace";
|
||||||
// hooks
|
// hooks
|
||||||
import { useUser, useUserProfile } from "@/hooks/store";
|
import { useUser, useUserProfile } from "@/hooks/store";
|
||||||
import { useAppRouter } from "@/hooks/use-app-router";
|
import { useAppRouter } from "@/hooks/use-app-router";
|
||||||
// wrappers
|
// wrappers
|
||||||
import { AuthenticationWrapper } from "@/lib/wrappers";
|
import { AuthenticationWrapper } from "@/lib/wrappers";
|
||||||
|
// plane web helpers
|
||||||
|
import { getIsWorkspaceCreationDisabled } from "@/plane-web/helpers/instance.helper";
|
||||||
// images
|
// images
|
||||||
import BlackHorizontalLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png";
|
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 WhiteHorizontalLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png";
|
||||||
|
import WorkspaceCreationDisabled from "@/public/workspace/workspace-creation-disabled.png";
|
||||||
|
|
||||||
const CreateWorkspacePage = observer(() => {
|
const CreateWorkspacePage = observer(() => {
|
||||||
// router
|
// router
|
||||||
|
|
@ -31,6 +35,8 @@ const CreateWorkspacePage = observer(() => {
|
||||||
});
|
});
|
||||||
// hooks
|
// hooks
|
||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
|
// derived values
|
||||||
|
const isWorkspaceCreationDisabled = getIsWorkspaceCreationDisabled();
|
||||||
|
|
||||||
const onSubmit = async (workspace: IWorkspace) => {
|
const onSubmit = async (workspace: IWorkspace) => {
|
||||||
await updateUserProfile({ last_workspace_id: workspace.id }).then(() => router.push(`/${workspace.slug}`));
|
await updateUserProfile({ last_workspace_id: workspace.id }).then(() => router.push(`/${workspace.slug}`));
|
||||||
|
|
@ -56,16 +62,38 @@ const CreateWorkspacePage = observer(() => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex h-full justify-center px-8 pb-8 sm:w-10/12 sm:items-center sm:justify-start sm:p-0 sm:pr-[8.33%] md:w-9/12 lg:w-4/5">
|
<div className="relative flex h-full justify-center px-8 pb-8 sm:w-10/12 sm:items-center sm:justify-start sm:p-0 sm:pr-[8.33%] md:w-9/12 lg:w-4/5">
|
||||||
<div className="w-full space-y-7 sm:space-y-10">
|
{isWorkspaceCreationDisabled ? (
|
||||||
<h4 className="text-2xl font-semibold">Create your workspace</h4>
|
<div className="w-4/5 h-full flex flex-col items-center justify-center text-lg font-medium gap-1">
|
||||||
<div className="sm:w-3/4 md:w-2/5">
|
<Image src={WorkspaceCreationDisabled} width={200} alt="Workspace creation disabled" className="mb-4" />
|
||||||
<CreateWorkspaceForm
|
<div className="text-lg font-medium text-center">Only your instance admin can create workspaces</div>
|
||||||
onSubmit={onSubmit}
|
<p className="text-sm text-custom-text-300 text-center">
|
||||||
defaultValues={defaultValues}
|
If you know your instance admin's email address, <br /> click the button below to get in touch with
|
||||||
setDefaultValues={setDefaultValues as any}
|
them.
|
||||||
/>
|
</p>
|
||||||
|
<div className="flex gap-4 mt-6">
|
||||||
|
<Button variant="primary" onClick={() => router.back()}>
|
||||||
|
Go back
|
||||||
|
</Button>
|
||||||
|
<a
|
||||||
|
href={`mailto:?subject=${encodeURIComponent("Requesting a new workspace")}&body=${encodeURIComponent(`Hi instance admin(s),\n\nPlease create a new workspace with the URL [/workspace-name] for [purpose of creating the workspace].\n\nThanks,\n${currentUser?.first_name} ${currentUser?.last_name}\n${currentUser?.email}`)}`}
|
||||||
|
className={getButtonStyling("outline-primary", "md")}
|
||||||
|
>
|
||||||
|
Request instance admin
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="w-full space-y-7 sm:space-y-10">
|
||||||
|
<h4 className="text-2xl font-semibold">Create your workspace</h4>
|
||||||
|
<div className="sm:w-3/4 md:w-2/5">
|
||||||
|
<CreateWorkspaceForm
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
defaultValues={defaultValues}
|
||||||
|
setDefaultValues={setDefaultValues as any}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AuthenticationWrapper>
|
</AuthenticationWrapper>
|
||||||
|
|
|
||||||
7
web/ce/helpers/instance.helper.ts
Normal file
7
web/ce/helpers/instance.helper.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { store } from "@/lib/store-context";
|
||||||
|
|
||||||
|
export const getIsWorkspaceCreationDisabled = () => {
|
||||||
|
const instanceConfig = store.instance.config;
|
||||||
|
|
||||||
|
return instanceConfig?.is_workspace_creation_disabled;
|
||||||
|
};
|
||||||
|
|
@ -21,30 +21,30 @@ type TAuthHeader = {
|
||||||
const Titles = {
|
const Titles = {
|
||||||
[EAuthModes.SIGN_IN]: {
|
[EAuthModes.SIGN_IN]: {
|
||||||
[EAuthSteps.EMAIL]: {
|
[EAuthSteps.EMAIL]: {
|
||||||
header: "Log in or Sign up",
|
header: "Log in or sign up",
|
||||||
subHeader: "",
|
subHeader: "",
|
||||||
},
|
},
|
||||||
[EAuthSteps.PASSWORD]: {
|
[EAuthSteps.PASSWORD]: {
|
||||||
header: "Log in or Sign up",
|
header: "Log in or sign up",
|
||||||
subHeader: "Log in using your password.",
|
subHeader: "Use your email-password combination to log in.",
|
||||||
},
|
},
|
||||||
[EAuthSteps.UNIQUE_CODE]: {
|
[EAuthSteps.UNIQUE_CODE]: {
|
||||||
header: "Log in or Sign up",
|
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]: {
|
[EAuthModes.SIGN_UP]: {
|
||||||
[EAuthSteps.EMAIL]: {
|
[EAuthSteps.EMAIL]: {
|
||||||
header: "Sign up or Log in",
|
header: "Sign up",
|
||||||
subHeader: "",
|
subHeader: "",
|
||||||
},
|
},
|
||||||
[EAuthSteps.PASSWORD]: {
|
[EAuthSteps.PASSWORD]: {
|
||||||
header: "Sign up or Log in",
|
header: "Sign up",
|
||||||
subHeader: "Sign up using your password",
|
subHeader: "Sign up using an email-password combination.",
|
||||||
},
|
},
|
||||||
[EAuthSteps.UNIQUE_CODE]: {
|
[EAuthSteps.UNIQUE_CODE]: {
|
||||||
header: "Sign up or Log in",
|
header: "Sign up",
|
||||||
subHeader: "Sign up using your unique code",
|
subHeader: "Sign up using a unique code sent to the email address above.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ export const AuthEmailForm: FC<TAuthEmailForm> = observer((props) => {
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
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`}
|
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"
|
autoComplete="on"
|
||||||
autoFocus
|
autoFocus
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,14 @@ import Image from "next/image";
|
||||||
// icons
|
// icons
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
// types
|
// types
|
||||||
|
import { OctagonAlert } from "lucide-react";
|
||||||
import { IWorkspaceMemberInvitation, TOnboardingSteps } from "@plane/types";
|
import { IWorkspaceMemberInvitation, TOnboardingSteps } from "@plane/types";
|
||||||
// components
|
// components
|
||||||
import { Invitations, OnboardingHeader, SwitchAccountDropdown, CreateWorkspace } from "@/components/onboarding";
|
import { Invitations, OnboardingHeader, SwitchAccountDropdown, CreateWorkspace } from "@/components/onboarding";
|
||||||
// hooks
|
// hooks
|
||||||
import { useUser } from "@/hooks/store";
|
import { useUser } from "@/hooks/store";
|
||||||
|
// plane web helpers
|
||||||
|
import { getIsWorkspaceCreationDisabled } from "@/plane-web/helpers/instance.helper";
|
||||||
// assets
|
// assets
|
||||||
import CreateJoinWorkspaceDark from "@/public/onboarding/create-join-workspace-dark.webp";
|
import CreateJoinWorkspaceDark from "@/public/onboarding/create-join-workspace-dark.webp";
|
||||||
import CreateJoinWorkspace from "@/public/onboarding/create-join-workspace-light.webp";
|
import CreateJoinWorkspace from "@/public/onboarding/create-join-workspace-light.webp";
|
||||||
|
|
@ -34,6 +37,8 @@ export const CreateOrJoinWorkspaces: React.FC<Props> = observer((props) => {
|
||||||
const { data: user } = useUser();
|
const { data: user } = useUser();
|
||||||
// hooks
|
// hooks
|
||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
|
// derived values
|
||||||
|
const isWorkspaceCreationEnabled = getIsWorkspaceCreationDisabled() === false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (invitations.length > 0) {
|
if (invitations.length > 0) {
|
||||||
|
|
@ -66,12 +71,25 @@ export const CreateOrJoinWorkspaces: React.FC<Props> = observer((props) => {
|
||||||
handleCurrentViewChange={() => setCurrentView(ECreateOrJoinWorkspaceViews.WORKSPACE_CREATE)}
|
handleCurrentViewChange={() => setCurrentView(ECreateOrJoinWorkspaceViews.WORKSPACE_CREATE)}
|
||||||
/>
|
/>
|
||||||
) : currentView === ECreateOrJoinWorkspaceViews.WORKSPACE_CREATE ? (
|
) : currentView === ECreateOrJoinWorkspaceViews.WORKSPACE_CREATE ? (
|
||||||
<CreateWorkspace
|
isWorkspaceCreationEnabled ? (
|
||||||
stepChange={stepChange}
|
<CreateWorkspace
|
||||||
user={user ?? undefined}
|
stepChange={stepChange}
|
||||||
invitedWorkspaces={invitations.length}
|
user={user ?? undefined}
|
||||||
handleCurrentViewChange={() => setCurrentView(ECreateOrJoinWorkspaceViews.WORKSPACE_JOIN)}
|
invitedWorkspaces={invitations.length}
|
||||||
/>
|
handleCurrentViewChange={() => setCurrentView(ECreateOrJoinWorkspaceViews.WORKSPACE_JOIN)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-96 w-full items-center justify-center">
|
||||||
|
<div className="flex gap-2.5 w-full items-start justify-center text-sm leading-5 mt-4 px-6 py-4 rounded border border-custom-primary-100/20 bg-custom-primary-100/10 text-custom-primary-200">
|
||||||
|
<OctagonAlert className="flex-shrink-0 size-5 mt-1" />
|
||||||
|
<span>
|
||||||
|
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.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-96 w-full items-center justify-center">
|
<div className="flex h-96 w-full items-center justify-center">
|
||||||
<LogoSpinner />
|
<LogoSpinner />
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,14 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
// constants
|
||||||
|
import { ORGANIZATION_SIZE, RESTRICTED_URLS } from "@plane/constants";
|
||||||
// types
|
// types
|
||||||
import { IUser, IWorkspace, TOnboardingSteps } from "@plane/types";
|
import { IUser, IWorkspace, TOnboardingSteps } from "@plane/types";
|
||||||
// ui
|
// ui
|
||||||
import { Button, CustomSelect, Input, Spinner, TOAST_TYPE, setToast } from "@plane/ui";
|
import { Button, CustomSelect, Input, Spinner, TOAST_TYPE, setToast } from "@plane/ui";
|
||||||
// constants
|
// constants
|
||||||
import { E_ONBOARDING, WORKSPACE_CREATED } from "@/constants/event-tracker";
|
import { E_ONBOARDING, WORKSPACE_CREATED } from "@/constants/event-tracker";
|
||||||
import { ORGANIZATION_SIZE, RESTRICTED_URLS } from "@/constants/workspace";
|
|
||||||
// hooks
|
// hooks
|
||||||
import { useEventTracker, useUserProfile, useUserSettings, useWorkspace } from "@/hooks/store";
|
import { useEventTracker, useUserProfile, useUserSettings, useWorkspace } from "@/hooks/store";
|
||||||
// services
|
// services
|
||||||
|
|
@ -154,18 +155,19 @@ export const CreateWorkspace: React.FC<Props> = observer((props) => {
|
||||||
className="text-sm text-onboarding-text-300 font-medium after:content-['*'] after:ml-0.5 after:text-red-500"
|
className="text-sm text-onboarding-text-300 font-medium after:content-['*'] after:ml-0.5 after:text-red-500"
|
||||||
htmlFor="name"
|
htmlFor="name"
|
||||||
>
|
>
|
||||||
Workspace name
|
Name your workspace
|
||||||
</label>
|
</label>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="name"
|
name="name"
|
||||||
rules={{
|
rules={{
|
||||||
required: "Workspace name is required",
|
required: "This is a required field.",
|
||||||
validate: (value) =>
|
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: {
|
maxLength: {
|
||||||
value: 80,
|
value: 80,
|
||||||
message: "Workspace name should not exceed 80 characters",
|
message: "Limit your name to 80 characters.",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
render={({ field: { value, ref, onChange } }) => (
|
render={({ field: { value, ref, onChange } }) => (
|
||||||
|
|
@ -182,7 +184,7 @@ export const CreateWorkspace: React.FC<Props> = observer((props) => {
|
||||||
shouldValidate: true,
|
shouldValidate: true,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
placeholder="Enter workspace name..."
|
placeholder="Something familiar and recognizable is always best."
|
||||||
ref={ref}
|
ref={ref}
|
||||||
hasError={Boolean(errors.name)}
|
hasError={Boolean(errors.name)}
|
||||||
className="w-full border-onboarding-border-100 placeholder:text-custom-text-400"
|
className="w-full border-onboarding-border-100 placeholder:text-custom-text-400"
|
||||||
|
|
@ -198,16 +200,16 @@ export const CreateWorkspace: React.FC<Props> = observer((props) => {
|
||||||
className="text-sm text-onboarding-text-300 font-medium after:content-['*'] after:ml-0.5 after:text-red-500"
|
className="text-sm text-onboarding-text-300 font-medium after:content-['*'] after:ml-0.5 after:text-red-500"
|
||||||
htmlFor="slug"
|
htmlFor="slug"
|
||||||
>
|
>
|
||||||
Workspace URL
|
Set your workspace's URL
|
||||||
</label>
|
</label>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="slug"
|
name="slug"
|
||||||
rules={{
|
rules={{
|
||||||
required: "Workspace slug is required",
|
required: "This is a required field.",
|
||||||
maxLength: {
|
maxLength: {
|
||||||
value: 48,
|
value: 48,
|
||||||
message: "Workspace slug should not exceed 48 characters",
|
message: "Limit your URL to 48 characters.",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
render={({ field: { value, ref, onChange } }) => (
|
render={({ field: { value, ref, onChange } }) => (
|
||||||
|
|
@ -223,20 +225,22 @@ export const CreateWorkspace: React.FC<Props> = observer((props) => {
|
||||||
type="text"
|
type="text"
|
||||||
value={value.toLocaleLowerCase().trim().replace(/ /g, "-")}
|
value={value.toLocaleLowerCase().trim().replace(/ /g, "-")}
|
||||||
onChange={(e) => {
|
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());
|
onChange(e.target.value.toLowerCase());
|
||||||
}}
|
}}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
hasError={Boolean(errors.slug)}
|
hasError={Boolean(errors.slug)}
|
||||||
|
placeholder="workspace-name"
|
||||||
className="w-full border-none !px-0"
|
className="w-full border-none !px-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-onboarding-text-300">You can only edit the slug of the URL</p>
|
<p className="text-sm text-onboarding-text-300">You can only edit the slug of the URL</p>
|
||||||
{slugError && <p className="-mt-3 text-sm text-red-500">Workspace URL is already taken!</p>}
|
{slugError && <p className="-mt-3 text-sm text-red-500">This URL is taken. Try something else.</p>}
|
||||||
{invalidSlug && (
|
{invalidSlug && (
|
||||||
<p className="text-sm text-red-500">{`URL can only contain ( - ), ( _ ) & alphanumeric characters.`}</p>
|
<p className="text-sm text-red-500">{`URLs can contain only ( - ), ( _ ) and alphanumeric characters.`}</p>
|
||||||
)}
|
)}
|
||||||
{errors.slug && <span className="text-sm text-red-500">{errors.slug.message}</span>}
|
{errors.slug && <span className="text-sm text-red-500">{errors.slug.message}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -246,20 +250,20 @@ export const CreateWorkspace: React.FC<Props> = observer((props) => {
|
||||||
className="text-sm text-onboarding-text-300 font-medium after:content-['*'] after:ml-0.5 after:text-red-500"
|
className="text-sm text-onboarding-text-300 font-medium after:content-['*'] after:ml-0.5 after:text-red-500"
|
||||||
htmlFor="organization_size"
|
htmlFor="organization_size"
|
||||||
>
|
>
|
||||||
Company size
|
How many people will use this workspace?
|
||||||
</label>
|
</label>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Controller
|
<Controller
|
||||||
name="organization_size"
|
name="organization_size"
|
||||||
control={control}
|
control={control}
|
||||||
rules={{ required: "This field is required" }}
|
rules={{ required: "This is a required field." }}
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
label={
|
label={
|
||||||
ORGANIZATION_SIZE.find((c) => c === value) ?? (
|
ORGANIZATION_SIZE.find((c) => c === value) ?? (
|
||||||
<span className="text-custom-text-400">Select organization size</span>
|
<span className="text-custom-text-400">Select a range</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
buttonClassName="!border-[0.5px] !border-onboarding-border-100 !shadow-none !rounded-md"
|
buttonClassName="!border-[0.5px] !border-onboarding-border-100 !shadow-none !rounded-md"
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,14 @@
|
||||||
import { Dispatch, SetStateAction, useEffect, useState, FC } from "react";
|
import { Dispatch, SetStateAction, useEffect, useState, FC } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
// constants
|
||||||
|
import { ORGANIZATION_SIZE, RESTRICTED_URLS } from "@plane/constants";
|
||||||
// types
|
// types
|
||||||
import { IWorkspace } from "@plane/types";
|
import { IWorkspace } from "@plane/types";
|
||||||
// ui
|
// ui
|
||||||
import { Button, CustomSelect, Input, TOAST_TYPE, setToast } from "@plane/ui";
|
import { Button, CustomSelect, Input, TOAST_TYPE, setToast } from "@plane/ui";
|
||||||
// constants
|
// constants
|
||||||
import { WORKSPACE_CREATED } from "@/constants/event-tracker";
|
import { WORKSPACE_CREATED } from "@/constants/event-tracker";
|
||||||
import { ORGANIZATION_SIZE, RESTRICTED_URLS } from "@/constants/workspace";
|
|
||||||
// hooks
|
// hooks
|
||||||
import { useEventTracker, useWorkspace } from "@/hooks/store";
|
import { useEventTracker, useWorkspace } from "@/hooks/store";
|
||||||
import { useAppRouter } from "@/hooks/use-app-router";
|
import { useAppRouter } from "@/hooks/use-app-router";
|
||||||
|
|
@ -40,8 +41,8 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
|
||||||
setDefaultValues,
|
setDefaultValues,
|
||||||
secondaryButton,
|
secondaryButton,
|
||||||
primaryButtonText = {
|
primaryButtonText = {
|
||||||
loading: "Creating...",
|
loading: "Creating workspace",
|
||||||
default: "Create Workspace",
|
default: "Create workspace",
|
||||||
},
|
},
|
||||||
} = props;
|
} = props;
|
||||||
// states
|
// states
|
||||||
|
|
@ -124,7 +125,7 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
|
||||||
<div className="space-y-6 sm:space-y-7">
|
<div className="space-y-6 sm:space-y-7">
|
||||||
<div className="space-y-1 text-sm">
|
<div className="space-y-1 text-sm">
|
||||||
<label htmlFor="workspaceName">
|
<label htmlFor="workspaceName">
|
||||||
Workspace Name
|
Name your workspace
|
||||||
<span className="ml-0.5 text-red-500">*</span>
|
<span className="ml-0.5 text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|
@ -132,12 +133,13 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
|
||||||
control={control}
|
control={control}
|
||||||
name="name"
|
name="name"
|
||||||
rules={{
|
rules={{
|
||||||
required: "Workspace name is required",
|
required: "This is a required field.",
|
||||||
validate: (value) =>
|
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: {
|
maxLength: {
|
||||||
value: 80,
|
value: 80,
|
||||||
message: "Workspace name should not exceed 80 characters",
|
message: "Limit your name to 80 characters.",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
render={({ field: { value, ref, onChange } }) => (
|
render={({ field: { value, ref, onChange } }) => (
|
||||||
|
|
@ -154,7 +156,7 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
|
||||||
}}
|
}}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
hasError={Boolean(errors.name)}
|
hasError={Boolean(errors.name)}
|
||||||
placeholder="Enter workspace name..."
|
placeholder="Something familiar and recognizable is always best."
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -164,7 +166,7 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 text-sm">
|
<div className="space-y-1 text-sm">
|
||||||
<label htmlFor="workspaceUrl">
|
<label htmlFor="workspaceUrl">
|
||||||
Workspace URL
|
Set your workspace's URL
|
||||||
<span className="ml-0.5 text-red-500">*</span>
|
<span className="ml-0.5 text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex w-full items-center rounded-md border-[0.5px] border-custom-border-200 px-3">
|
<div className="flex w-full items-center rounded-md border-[0.5px] border-custom-border-200 px-3">
|
||||||
|
|
@ -173,10 +175,10 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
|
||||||
control={control}
|
control={control}
|
||||||
name="slug"
|
name="slug"
|
||||||
rules={{
|
rules={{
|
||||||
required: "Workspace slug is required",
|
required: "This is a required field.",
|
||||||
maxLength: {
|
maxLength: {
|
||||||
value: 48,
|
value: 48,
|
||||||
message: "Workspace slug should not exceed 48 characters",
|
message: "Limit your URL to 48 characters.",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
render={({ field: { onChange, value, ref } }) => (
|
render={({ field: { onChange, value, ref } }) => (
|
||||||
|
|
@ -185,12 +187,13 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
|
||||||
type="text"
|
type="text"
|
||||||
value={value.toLocaleLowerCase().trim().replace(/ /g, "-")}
|
value={value.toLocaleLowerCase().trim().replace(/ /g, "-")}
|
||||||
onChange={(e) => {
|
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());
|
onChange(e.target.value.toLowerCase());
|
||||||
}}
|
}}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
hasError={Boolean(errors.slug)}
|
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"
|
className="block w-full rounded-md border-none bg-transparent !px-0 py-2 text-sm"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -198,26 +201,26 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
|
||||||
</div>
|
</div>
|
||||||
{slugError && <p className="-mt-3 text-sm text-red-500">Workspace URL is already taken!</p>}
|
{slugError && <p className="-mt-3 text-sm text-red-500">Workspace URL is already taken!</p>}
|
||||||
{invalidSlug && (
|
{invalidSlug && (
|
||||||
<p className="text-sm text-red-500">{`URL can only contain ( - ), ( _ ) & alphanumeric characters.`}</p>
|
<p className="text-sm text-red-500">{`URLs can contain only ( - ), ( _ ) and alphanumeric characters.`}</p>
|
||||||
)}
|
)}
|
||||||
{errors.slug && <span className="text-xs text-red-500">{errors.slug.message}</span>}
|
{errors.slug && <span className="text-xs text-red-500">{errors.slug.message}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 text-sm">
|
<div className="space-y-1 text-sm">
|
||||||
<span>
|
<span>
|
||||||
What size is your organization?<span className="ml-0.5 text-red-500">*</span>
|
How many people will use this workspace?<span className="ml-0.5 text-red-500">*</span>
|
||||||
</span>
|
</span>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Controller
|
<Controller
|
||||||
name="organization_size"
|
name="organization_size"
|
||||||
control={control}
|
control={control}
|
||||||
rules={{ required: "This field is required" }}
|
rules={{ required: "This is a required field." }}
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
label={
|
label={
|
||||||
ORGANIZATION_SIZE.find((c) => c === value) ?? (
|
ORGANIZATION_SIZE.find((c) => c === value) ?? (
|
||||||
<span className="text-custom-text-400">Select organization size</span>
|
<span className="text-custom-text-400">Select a range</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
buttonClassName="!border-[0.5px] !border-custom-border-200 !shadow-none"
|
buttonClassName="!border-[0.5px] !border-custom-border-200 !shadow-none"
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ import { useEffect, useState, FC } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { Pencil } from "lucide-react";
|
import { Pencil } from "lucide-react";
|
||||||
|
// constants
|
||||||
|
import { ORGANIZATION_SIZE } from "@plane/constants";
|
||||||
|
// types
|
||||||
import { IWorkspace } from "@plane/types";
|
import { IWorkspace } from "@plane/types";
|
||||||
// ui
|
// ui
|
||||||
import { Button, CustomSelect, Input, TOAST_TYPE, setToast } from "@plane/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";
|
import { WorkspaceImageUploadModal } from "@/components/core";
|
||||||
// constants
|
// constants
|
||||||
import { WORKSPACE_UPDATED } from "@/constants/event-tracker";
|
import { WORKSPACE_UPDATED } from "@/constants/event-tracker";
|
||||||
import { ORGANIZATION_SIZE } from "@/constants/workspace";
|
|
||||||
// helpers
|
// helpers
|
||||||
import { getFileURL } from "@/helpers/file.helper";
|
import { getFileURL } from "@/helpers/file.helper";
|
||||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@ import { getFileURL } from "@/helpers/file.helper";
|
||||||
import { useAppTheme, useUser, useUserPermissions, useUserProfile, useWorkspace } from "@/hooks/store";
|
import { useAppTheme, useUser, useUserPermissions, useUserProfile, useWorkspace } from "@/hooks/store";
|
||||||
// plane web constants
|
// plane web constants
|
||||||
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
|
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";
|
import { WorkspaceLogo } from "../logo";
|
||||||
|
|
||||||
// Static Data
|
// Static Data
|
||||||
|
|
@ -53,6 +56,8 @@ export const SidebarDropdown = observer(() => {
|
||||||
} = useUser();
|
} = useUser();
|
||||||
const { updateUserProfile } = useUserProfile();
|
const { updateUserProfile } = useUserProfile();
|
||||||
const { allowPermissions } = useUserPermissions();
|
const { allowPermissions } = useUserPermissions();
|
||||||
|
// derived values
|
||||||
|
const isWorkspaceCreationEnabled = getIsWorkspaceCreationDisabled() === false;
|
||||||
|
|
||||||
const isUserInstanceAdmin = false;
|
const isUserInstanceAdmin = false;
|
||||||
const { currentWorkspace: activeWorkspace, workspaces } = useWorkspace();
|
const { currentWorkspace: activeWorkspace, workspaces } = useWorkspace();
|
||||||
|
|
@ -205,15 +210,17 @@ export const SidebarDropdown = observer(() => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex flex-col items-start justify-start gap-2 px-4 py-2 text-sm">
|
<div className="w-full flex flex-col items-start justify-start gap-2 px-4 py-2 text-sm">
|
||||||
<Link href="/create-workspace" className="w-full">
|
{isWorkspaceCreationEnabled && (
|
||||||
<Menu.Item
|
<Link href="/create-workspace" className="w-full">
|
||||||
as="div"
|
<Menu.Item
|
||||||
className="flex items-center gap-2 rounded px-2 py-1 text-sm font-medium text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80"
|
as="div"
|
||||||
>
|
className="flex items-center gap-2 rounded px-2 py-1 text-sm font-medium text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80"
|
||||||
<PlusSquare strokeWidth={1.75} className="h-4 w-4 flex-shrink-0" />
|
>
|
||||||
Create workspace
|
<PlusSquare strokeWidth={1.75} className="h-4 w-4 flex-shrink-0" />
|
||||||
</Menu.Item>
|
Create workspace
|
||||||
</Link>
|
</Menu.Item>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
{userLinks(workspaceSlug?.toString() ?? "").map(
|
{userLinks(workspaceSlug?.toString() ?? "").map(
|
||||||
(link, index) =>
|
(link, index) =>
|
||||||
allowPermissions(link.access, EUserPermissionsLevel.WORKSPACE) && (
|
allowPermissions(link.access, EUserPermissionsLevel.WORKSPACE) && (
|
||||||
|
|
|
||||||
|
|
@ -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 = [
|
export const USER_ROLES = [
|
||||||
{ value: "Product / Project Manager", label: "Product / Project Manager" },
|
{ value: "Product / Project Manager", label: "Product / Project Manager" },
|
||||||
{ value: "Development / Engineering", label: "Development / Engineering" },
|
{ value: "Development / Engineering", label: "Development / Engineering" },
|
||||||
|
|
@ -106,18 +104,3 @@ export const DEFAULT_GLOBAL_VIEWS_LIST: {
|
||||||
label: "Subscribed",
|
label: "Subscribed",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const RESTRICTED_URLS = [
|
|
||||||
"404",
|
|
||||||
"accounts",
|
|
||||||
"api",
|
|
||||||
"create-workspace",
|
|
||||||
"error",
|
|
||||||
"god-mode",
|
|
||||||
"installations",
|
|
||||||
"invitations",
|
|
||||||
"onboarding",
|
|
||||||
"profile",
|
|
||||||
"spaces",
|
|
||||||
"workspace-invitations",
|
|
||||||
];
|
|
||||||
|
|
|
||||||
1
web/ee/helpers/instance.helper.ts
Normal file
1
web/ee/helpers/instance.helper.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "ce/helpers/instance.helper";
|
||||||
BIN
web/public/workspace/workspace-creation-disabled.png
Normal file
BIN
web/public/workspace/workspace-creation-disabled.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 187 KiB |
Loading…
Add table
Add a link
Reference in a new issue