Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 32b4019a69 | |||
| 135024a940 | |||
| 8500c63205 | |||
| c8736f13ec | |||
| e43b4b3d47 | |||
| 59f0e9fe2c | |||
| 56956d8786 | |||
| cc455b0e76 | |||
| 8705a96220 | |||
| 190c85468b | |||
| d3d723cadd | |||
| 6828d33c3f | |||
| 10e67144a0 | |||
| 0e63128d45 | |||
| c9cf7cc631 | |||
| 413d6d21b4 | |||
| 909fe128f1 | |||
| 6a997bb9da | |||
| 0dc0a2a8a8 | |||
| e01e736ffe | |||
| ae3dcc3dbd | |||
| c4f5093492 | |||
| cf8053825b | |||
| fc3e63f67a | |||
| c5cac27026 | |||
| 072e8213f0 | |||
| 5eb8e76b3b | |||
| bd0799f5e7 | |||
| aba2af9a7c | |||
| 1028ec8735 | |||
| c99579cddc | |||
| bba10d7073 | |||
| a8f4d21e8b | |||
| c5731ce454 | |||
| 8f091b7d7e | |||
| 84236f506b | |||
| 5bbb796e5e | |||
| 59256588db | |||
| 2a740b9cd9 | |||
| 831a336690 | |||
| 244986554c | |||
| 0aca5c7a86 | |||
| 71c77d30a0 | |||
| c5b1d95c76 | |||
| 707c4f9e8d | |||
| fd9f0fb17c | |||
| d1bfed950a | |||
| cfbc0cf2e1 | |||
| 299e220d08 | |||
| 64bbe19f1b | |||
| 92ea5998c5 | |||
| 99e1963d9b | |||
| b3626d815f | |||
| 9145234a6c | |||
| 78d4d981d1 | |||
| c9147e7a57 | |||
| b8ee986636 | |||
| f5f5726e15 | |||
| a72d095e60 | |||
| 5183f4439b | |||
| e5d2902a41 | |||
| 7f97f234d0 | |||
| 645764e53d | |||
| d75e33ccf0 | |||
| ab3a00dd6e | |||
| 52617baf0e | |||
| ee4ad580fc | |||
| d9c8271f35 | |||
| e79b0c40ec | |||
| 0b4b092eaa | |||
| 703aac597c | |||
| c24be25024 |
@@ -48,7 +48,7 @@ Meet [Plane](https://dub.sh/plane-website-readme), an open-source project manage
|
||||
|
||||
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account.
|
||||
|
||||
If you would like to self-host Plane, please see our [deployment guide](https://docs.plane.so/self-hosting/overview).
|
||||
If you would like to self-host Plane, please see our [deployment guide](https://docs.plane.so/docker-compose).
|
||||
|
||||
| Installation methods | Docs link |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import Link from "next/link";
|
||||
// icons
|
||||
import { Settings2 } from "lucide-react";
|
||||
// types
|
||||
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
|
||||
// ui
|
||||
import { ToggleSwitch, getButtonStyling } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
disabled: boolean;
|
||||
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
|
||||
};
|
||||
|
||||
export const GitlabConfiguration: React.FC<Props> = observer((props) => {
|
||||
const { disabled, updateConfig } = props;
|
||||
// store
|
||||
const { formattedConfig } = useInstance();
|
||||
// derived values
|
||||
const enableGitlabConfig = formattedConfig?.IS_GITLAB_ENABLED ?? "";
|
||||
const isGitlabConfigured = !!formattedConfig?.GITLAB_CLIENT_ID && !!formattedConfig?.GITLAB_CLIENT_SECRET;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isGitlabConfigured ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/authentication/gitlab" className={cn(getButtonStyling("link-primary", "md"), "font-medium")}>
|
||||
Edit
|
||||
</Link>
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableGitlabConfig))}
|
||||
onChange={() => {
|
||||
Boolean(parseInt(enableGitlabConfig)) === true
|
||||
? updateConfig("IS_GITLAB_ENABLED", "0")
|
||||
: updateConfig("IS_GITLAB_ENABLED", "1");
|
||||
}}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
href="/authentication/gitlab"
|
||||
className={cn(getButtonStyling("neutral-primary", "sm"), "text-custom-text-300")}
|
||||
>
|
||||
<Settings2 className="h-4 w-4 p-0.5 text-custom-text-300/80" />
|
||||
Configure
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./email-config-switch";
|
||||
export * from "./password-config-switch";
|
||||
export * from "./authentication-method-card";
|
||||
export * from "./gitlab-config";
|
||||
export * from "./github-config";
|
||||
export * from "./google-config";
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { FC, useState } from "react";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import Link from "next/link";
|
||||
import { useForm } from "react-hook-form";
|
||||
// types
|
||||
import { IFormattedInstanceConfiguration, TInstanceGitlabAuthenticationConfigurationKeys } from "@plane/types";
|
||||
// ui
|
||||
import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui";
|
||||
// components
|
||||
import {
|
||||
ConfirmDiscardModal,
|
||||
ControllerInput,
|
||||
CopyField,
|
||||
TControllerInputFormField,
|
||||
TCopyField,
|
||||
} from "@/components/common";
|
||||
// helpers
|
||||
import { API_BASE_URL, cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
config: IFormattedInstanceConfiguration;
|
||||
};
|
||||
|
||||
type GitlabConfigFormValues = Record<TInstanceGitlabAuthenticationConfigurationKeys, string>;
|
||||
|
||||
export const InstanceGitlabConfigForm: FC<Props> = (props) => {
|
||||
const { config } = props;
|
||||
// states
|
||||
const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false);
|
||||
// store hooks
|
||||
const { updateInstanceConfigurations } = useInstance();
|
||||
// form data
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
formState: { errors, isDirty, isSubmitting },
|
||||
} = useForm<GitlabConfigFormValues>({
|
||||
defaultValues: {
|
||||
GITLAB_HOST: config["GITLAB_HOST"],
|
||||
GITLAB_CLIENT_ID: config["GITLAB_CLIENT_ID"],
|
||||
GITLAB_CLIENT_SECRET: config["GITLAB_CLIENT_SECRET"],
|
||||
},
|
||||
});
|
||||
|
||||
const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : "";
|
||||
|
||||
const GITLAB_FORM_FIELDS: TControllerInputFormField[] = [
|
||||
{
|
||||
key: "GITLAB_HOST",
|
||||
type: "text",
|
||||
label: "Host",
|
||||
description: (
|
||||
<>
|
||||
This is the <b>GitLab host</b> to use for login, <b>including scheme</b>.
|
||||
</>
|
||||
),
|
||||
placeholder: "https://gitlab.com",
|
||||
error: Boolean(errors.GITLAB_HOST),
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "GITLAB_CLIENT_ID",
|
||||
type: "text",
|
||||
label: "Application ID",
|
||||
description: (
|
||||
<>
|
||||
Get this from your{" "}
|
||||
<a
|
||||
tabIndex={-1}
|
||||
href="https://docs.gitlab.com/ee/integration/oauth_provider.html"
|
||||
target="_blank"
|
||||
className="text-custom-primary-100 hover:underline"
|
||||
rel="noreferrer"
|
||||
>
|
||||
GitLab OAuth application settings
|
||||
</a>
|
||||
.
|
||||
</>
|
||||
),
|
||||
placeholder: "c2ef2e7fc4e9d15aa7630f5637d59e8e4a27ff01dceebdb26b0d267b9adcf3c3",
|
||||
error: Boolean(errors.GITLAB_CLIENT_ID),
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "GITLAB_CLIENT_SECRET",
|
||||
type: "password",
|
||||
label: "Secret",
|
||||
description: (
|
||||
<>
|
||||
The client secret is also found in your{" "}
|
||||
<a
|
||||
tabIndex={-1}
|
||||
href="https://docs.gitlab.com/ee/integration/oauth_provider.html"
|
||||
target="_blank"
|
||||
className="text-custom-primary-100 hover:underline"
|
||||
rel="noreferrer"
|
||||
>
|
||||
GitLab OAuth application settings
|
||||
</a>
|
||||
.
|
||||
</>
|
||||
),
|
||||
placeholder: "gloas-f79cfa9a03c97f6ffab303177a5a6778a53c61e3914ba093412f68a9298a1b28",
|
||||
error: Boolean(errors.GITLAB_CLIENT_SECRET),
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
const GITLAB_SERVICE_FIELD: TCopyField[] = [
|
||||
{
|
||||
key: "Callback_URL",
|
||||
label: "Callback URL",
|
||||
url: `${originURL}/auth/gitlab/callback/`,
|
||||
description: (
|
||||
<>
|
||||
We will auto-generate this. Paste this into the <b>Redirect URI</b> field of your{" "}
|
||||
<a
|
||||
tabIndex={-1}
|
||||
href="https://docs.gitlab.com/ee/integration/oauth_provider.html"
|
||||
target="_blank"
|
||||
className="text-custom-primary-100 hover:underline"
|
||||
rel="noreferrer"
|
||||
>
|
||||
GitLab OAuth application
|
||||
</a>
|
||||
.
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit = async (formData: GitlabConfigFormValues) => {
|
||||
const payload: Partial<GitlabConfigFormValues> = { ...formData };
|
||||
|
||||
await updateInstanceConfigurations(payload)
|
||||
.then((response = []) => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success",
|
||||
message: "GitLab Configuration Settings updated successfully",
|
||||
});
|
||||
reset({
|
||||
GITLAB_HOST: response.find((item) => item.key === "GITLAB_HOST")?.value,
|
||||
GITLAB_CLIENT_ID: response.find((item) => item.key === "GITLAB_CLIENT_ID")?.value,
|
||||
GITLAB_CLIENT_SECRET: response.find((item) => item.key === "GITLAB_CLIENT_SECRET")?.value,
|
||||
});
|
||||
})
|
||||
.catch((err) => console.error(err));
|
||||
};
|
||||
|
||||
const handleGoBack = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
|
||||
if (isDirty) {
|
||||
e.preventDefault();
|
||||
setIsDiscardChangesModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmDiscardModal
|
||||
isOpen={isDiscardChangesModalOpen}
|
||||
onDiscardHref="/authentication"
|
||||
handleClose={() => setIsDiscardChangesModalOpen(false)}
|
||||
/>
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="grid grid-cols-2 gap-x-12 gap-y-8 w-full">
|
||||
<div className="flex flex-col gap-y-4 col-span-2 md:col-span-1">
|
||||
<div className="pt-2 text-xl font-medium">Configuration</div>
|
||||
{GITLAB_FORM_FIELDS.map((field) => (
|
||||
<ControllerInput
|
||||
key={field.key}
|
||||
control={control}
|
||||
type={field.type}
|
||||
name={field.key}
|
||||
label={field.label}
|
||||
description={field.description}
|
||||
placeholder={field.placeholder}
|
||||
error={field.error}
|
||||
required={field.required}
|
||||
/>
|
||||
))}
|
||||
<div className="flex flex-col gap-1 pt-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting} disabled={!isDirty}>
|
||||
{isSubmitting ? "Saving..." : "Save changes"}
|
||||
</Button>
|
||||
<Link
|
||||
href="/authentication"
|
||||
className={cn(getButtonStyling("link-neutral", "md"), "font-medium")}
|
||||
onClick={handleGoBack}
|
||||
>
|
||||
Go back
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
<div className="flex flex-col gap-y-4 px-6 py-4 my-2 bg-custom-background-80/60 rounded-lg">
|
||||
<div className="pt-2 text-xl font-medium">Service provider details</div>
|
||||
{GITLAB_SERVICE_FIELD.map((field) => (
|
||||
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import Image from "next/image";
|
||||
import useSWR from "swr";
|
||||
import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui";
|
||||
// components
|
||||
import { PageHeader } from "@/components/core";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
// icons
|
||||
import GitlabLogo from "@/public/logos/gitlab-logo.svg";
|
||||
// local components
|
||||
import { AuthenticationMethodCard } from "../components";
|
||||
import { InstanceGitlabConfigForm } from "./form";
|
||||
|
||||
const InstanceGitlabAuthenticationPage = observer(() => {
|
||||
// store
|
||||
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
|
||||
// state
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
// config
|
||||
const enableGitlabConfig = formattedConfig?.IS_GITLAB_ENABLED ?? "";
|
||||
|
||||
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
|
||||
|
||||
const updateConfig = async (key: "IS_GITLAB_ENABLED", value: string) => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
const payload = {
|
||||
[key]: value,
|
||||
};
|
||||
|
||||
const updateConfigPromise = updateInstanceConfigurations(payload);
|
||||
|
||||
setPromiseToast(updateConfigPromise, {
|
||||
loading: "Saving Configuration...",
|
||||
success: {
|
||||
title: "Configuration saved",
|
||||
message: () => `GitLab authentication is now ${value ? "active" : "disabled"}.`,
|
||||
},
|
||||
error: {
|
||||
title: "Error",
|
||||
message: () => "Failed to save configuration",
|
||||
},
|
||||
});
|
||||
|
||||
await updateConfigPromise
|
||||
.then(() => {
|
||||
setIsSubmitting(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setIsSubmitting(false);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Authentication - God Mode" />
|
||||
<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">
|
||||
<AuthenticationMethodCard
|
||||
name="GitLab"
|
||||
description="Allow members to login or sign up to plane with their GitLab accounts."
|
||||
icon={<Image src={GitlabLogo} height={24} width={24} alt="GitLab Logo" />}
|
||||
config={
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableGitlabConfig))}
|
||||
onChange={() => {
|
||||
Boolean(parseInt(enableGitlabConfig)) === true
|
||||
? updateConfig("IS_GITLAB_ENABLED", "0")
|
||||
: updateConfig("IS_GITLAB_ENABLED", "1");
|
||||
}}
|
||||
size="sm"
|
||||
disabled={isSubmitting || !formattedConfig}
|
||||
/>
|
||||
}
|
||||
disabled={isSubmitting || !formattedConfig}
|
||||
withBorder={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md p-4">
|
||||
{formattedConfig ? (
|
||||
<InstanceGitlabConfigForm config={formattedConfig} />
|
||||
) : (
|
||||
<Loader className="space-y-8">
|
||||
<Loader.Item height="50px" width="25%" />
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" width="50%" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default InstanceGitlabAuthenticationPage;
|
||||
@@ -17,12 +17,14 @@ import { useInstance } from "@/hooks/store";
|
||||
// images
|
||||
import githubLightModeImage from "@/public/logos/github-black.png";
|
||||
import githubDarkModeImage from "@/public/logos/github-white.png";
|
||||
import GitlabLogo from "@/public/logos/gitlab-logo.svg";
|
||||
import GoogleLogo from "@/public/logos/google-logo.svg";
|
||||
// local components
|
||||
import {
|
||||
AuthenticationMethodCard,
|
||||
EmailCodesConfiguration,
|
||||
PasswordLoginConfiguration,
|
||||
GitlabConfiguration,
|
||||
GithubConfiguration,
|
||||
GoogleConfiguration,
|
||||
} from "./components";
|
||||
@@ -116,6 +118,13 @@ const InstanceAuthenticationPage = observer(() => {
|
||||
),
|
||||
config: <GithubConfiguration disabled={isSubmitting} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
key: "gitlab",
|
||||
name: "GitLab",
|
||||
description: "Allow members to login or sign up to plane with their GitLab accounts.",
|
||||
icon: <Image src={GitlabLogo} height={20} width={20} alt="GitLab Logo" />,
|
||||
config: <GitlabConfiguration disabled={isSubmitting} updateConfig={updateConfig} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -41,10 +41,10 @@ export const InstanceSidebar: FC<IInstanceSidebar> = observer(() => {
|
||||
<div
|
||||
className={`inset-y-0 z-20 flex h-full flex-shrink-0 flex-grow-0 flex-col border-r border-custom-sidebar-border-200 bg-custom-sidebar-background-100 duration-300
|
||||
fixed md:relative
|
||||
${isSidebarCollapsed ? "-ml-[280px]" : ""}
|
||||
sm:${isSidebarCollapsed ? "-ml-[280px]" : ""}
|
||||
md:ml-0 ${isSidebarCollapsed ? "w-[80px]" : "w-[280px]"}
|
||||
lg:ml-0 ${isSidebarCollapsed ? "w-[80px]" : "w-[280px]"}
|
||||
${isSidebarCollapsed ? "-ml-[250px]" : ""}
|
||||
sm:${isSidebarCollapsed ? "-ml-[250px]" : ""}
|
||||
md:ml-0 ${isSidebarCollapsed ? "w-[70px]" : "w-[250px]"}
|
||||
lg:ml-0 ${isSidebarCollapsed ? "w-[70px]" : "w-[250px]"}
|
||||
`}
|
||||
>
|
||||
<div ref={ref} className="flex h-full w-full flex-1 flex-col">
|
||||
|
||||
@@ -31,6 +31,8 @@ export const InstanceHeader: FC = observer(() => {
|
||||
return "Google";
|
||||
case "github":
|
||||
return "Github";
|
||||
case "gitlab":
|
||||
return "GitLab";
|
||||
default:
|
||||
return pathName.toUpperCase();
|
||||
}
|
||||
|
||||
@@ -319,6 +319,8 @@ export const InstanceSetupForm: FC = (props) => {
|
||||
<div className="relative flex items-center pt-2 gap-2">
|
||||
<div>
|
||||
<Checkbox
|
||||
className="w-4 h-4"
|
||||
iconClassName="w-3 h-3"
|
||||
id="is_telemetry_enabled"
|
||||
onChange={() => handleFormChange("is_telemetry_enabled", !formData.is_telemetry_enabled)}
|
||||
checked={formData.is_telemetry_enabled}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="80 80 220 220"><defs><style>.cls-1{fill:#e24329;}.cls-2{fill:#fc6d26;}.cls-3{fill:#fca326;}</style></defs><g id="LOGO"><path class="cls-1" d="M282.83,170.73l-.27-.69-26.14-68.22a6.81,6.81,0,0,0-2.69-3.24,7,7,0,0,0-8,.43,7,7,0,0,0-2.32,3.52l-17.65,54H154.29l-17.65-54A6.86,6.86,0,0,0,134.32,99a7,7,0,0,0-8-.43,6.87,6.87,0,0,0-2.69,3.24L97.44,170l-.26.69a48.54,48.54,0,0,0,16.1,56.1l.09.07.24.17,39.82,29.82,19.7,14.91,12,9.06a8.07,8.07,0,0,0,9.76,0l12-9.06,19.7-14.91,40.06-30,.1-.08A48.56,48.56,0,0,0,282.83,170.73Z"/><path class="cls-2" d="M282.83,170.73l-.27-.69a88.3,88.3,0,0,0-35.15,15.8L190,229.25c19.55,14.79,36.57,27.64,36.57,27.64l40.06-30,.1-.08A48.56,48.56,0,0,0,282.83,170.73Z"/><path class="cls-3" d="M153.43,256.89l19.7,14.91,12,9.06a8.07,8.07,0,0,0,9.76,0l12-9.06,19.7-14.91S209.55,244,190,229.25C170.45,244,153.43,256.89,153.43,256.89Z"/><path class="cls-2" d="M132.58,185.84A88.19,88.19,0,0,0,97.44,170l-.26.69a48.54,48.54,0,0,0,16.1,56.1l.09.07.24.17,39.82,29.82s17-12.85,36.57-27.64Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
Regular → Executable
Regular → Executable
@@ -20,7 +20,15 @@ class PageSerializer(BaseSerializer):
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
project = serializers.UUIDField(read_only=True)
|
||||
# Many to many
|
||||
label_ids = serializers.ListField(
|
||||
child=serializers.UUIDField(),
|
||||
required=False,
|
||||
)
|
||||
project_ids = serializers.ListField(
|
||||
child=serializers.UUIDField(),
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Page
|
||||
@@ -42,18 +50,14 @@ class PageSerializer(BaseSerializer):
|
||||
"updated_by",
|
||||
"view_props",
|
||||
"logo_props",
|
||||
"project",
|
||||
"label_ids",
|
||||
"project_ids",
|
||||
]
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"owned_by",
|
||||
]
|
||||
|
||||
def to_representation(self, instance):
|
||||
data = super().to_representation(instance)
|
||||
data["labels"] = [str(label.id) for label in instance.labels.all()]
|
||||
return data
|
||||
|
||||
def create(self, validated_data):
|
||||
labels = validated_data.pop("labels", None)
|
||||
project_id = self.context["project_id"]
|
||||
@@ -63,6 +67,7 @@ class PageSerializer(BaseSerializer):
|
||||
# Get the workspace id from the project
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
# Create the page
|
||||
page = Page.objects.create(
|
||||
**validated_data,
|
||||
description_html=description_html,
|
||||
@@ -70,6 +75,7 @@ class PageSerializer(BaseSerializer):
|
||||
workspace_id=project.workspace_id,
|
||||
)
|
||||
|
||||
# Create the project page
|
||||
ProjectPage.objects.create(
|
||||
workspace_id=page.workspace_id,
|
||||
project_id=project_id,
|
||||
@@ -78,6 +84,7 @@ class PageSerializer(BaseSerializer):
|
||||
updated_by_id=page.updated_by_id,
|
||||
)
|
||||
|
||||
# Create page labels
|
||||
if labels is not None:
|
||||
PageLabel.objects.bulk_create(
|
||||
[
|
||||
|
||||
@@ -180,7 +180,8 @@ from .page.base import (
|
||||
PagesDescriptionViewSet,
|
||||
)
|
||||
|
||||
from .search import GlobalSearchEndpoint, IssueSearchEndpoint
|
||||
from .search.base import GlobalSearchEndpoint
|
||||
from .search.issue import IssueSearchEndpoint
|
||||
|
||||
|
||||
from .external.base import (
|
||||
|
||||
@@ -62,12 +62,15 @@ class BulkEstimatePointEndpoint(BaseViewSet):
|
||||
path="/api/workspaces/:slug/estimates/", url_params=True, user=False
|
||||
)
|
||||
def create(self, request, slug, project_id):
|
||||
estimate = request.data.get('estimate')
|
||||
estimate = request.data.get("estimate")
|
||||
estimate_name = estimate.get("name", generate_random_name())
|
||||
estimate_type = estimate.get("type", 'categories')
|
||||
estimate_type = estimate.get("type", "categories")
|
||||
last_used = estimate.get("last_used", False)
|
||||
estimate = Estimate.objects.create(
|
||||
name=estimate_name, project_id=project_id, last_used=last_used, type=estimate_type
|
||||
name=estimate_name,
|
||||
project_id=project_id,
|
||||
last_used=last_used,
|
||||
type=estimate_type,
|
||||
)
|
||||
|
||||
estimate_points = request.data.get("estimate_points", [])
|
||||
@@ -125,8 +128,12 @@ class BulkEstimatePointEndpoint(BaseViewSet):
|
||||
estimate = Estimate.objects.get(pk=estimate_id)
|
||||
|
||||
if request.data.get("estimate"):
|
||||
estimate.name = request.data.get("estimate").get("name", estimate.name)
|
||||
estimate.type = request.data.get("estimate").get("type", estimate.type)
|
||||
estimate.name = request.data.get("estimate").get(
|
||||
"name", estimate.name
|
||||
)
|
||||
estimate.type = request.data.get("estimate").get(
|
||||
"type", estimate.type
|
||||
)
|
||||
estimate.save()
|
||||
|
||||
estimate_points_data = request.data.get("estimate_points", [])
|
||||
@@ -204,7 +211,9 @@ class EstimatePointEndpoint(BaseViewSet):
|
||||
serializer = EstimatePointSerializer(estimate_point).data
|
||||
return Response(serializer, status=status.HTTP_200_OK)
|
||||
|
||||
def partial_update(self, request, slug, project_id, estimate_id, estimate_point_id):
|
||||
def partial_update(
|
||||
self, request, slug, project_id, estimate_id, estimate_point_id
|
||||
):
|
||||
# TODO: add a key validation if the same key already exists
|
||||
estimate_point = EstimatePoint.objects.get(
|
||||
pk=estimate_point_id,
|
||||
@@ -225,7 +234,7 @@ class EstimatePointEndpoint(BaseViewSet):
|
||||
def destroy(
|
||||
self, request, slug, project_id, estimate_id, estimate_point_id
|
||||
):
|
||||
new_estimate_id = request.GET.get("new_estimate_id", None)
|
||||
new_estimate_id = request.data.get("new_estimate_id", None)
|
||||
estimate_points = EstimatePoint.objects.filter(
|
||||
estimate_id=estimate_id,
|
||||
project_id=project_id,
|
||||
@@ -236,8 +245,8 @@ class EstimatePointEndpoint(BaseViewSet):
|
||||
_ = Issue.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
estimate_id=estimate_point_id,
|
||||
).update(estimate_id=new_estimate_id)
|
||||
estimate_point_id=estimate_point_id,
|
||||
).update(estimate_point_id=new_estimate_id)
|
||||
|
||||
# delete the estimate point
|
||||
old_estimate_point = EstimatePoint.objects.filter(
|
||||
|
||||
@@ -6,10 +6,13 @@ from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
# Django imports
|
||||
from django.db import connection
|
||||
from django.db.models import Exists, OuterRef, Q, Subquery
|
||||
from django.db.models import Exists, OuterRef, Q, Value, UUIDField
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from django.http import StreamingHttpResponse
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
@@ -70,9 +73,6 @@ class PageViewSet(BaseViewSet):
|
||||
entity_identifier=OuterRef("pk"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
)
|
||||
project_subquery = ProjectPage.objects.filter(
|
||||
page_id=OuterRef("id"), project_id=self.kwargs.get("project_id")
|
||||
).values_list("project_id", flat=True)[:1]
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
@@ -91,8 +91,33 @@ class PageViewSet(BaseViewSet):
|
||||
.order_by(self.request.GET.get("order_by", "-created_at"))
|
||||
.prefetch_related("labels")
|
||||
.order_by("-is_favorite", "-created_at")
|
||||
.annotate(project=Subquery(project_subquery))
|
||||
.filter(project=self.kwargs.get("project_id"))
|
||||
.annotate(
|
||||
project=Exists(
|
||||
ProjectPage.objects.filter(
|
||||
page_id=OuterRef("id"),
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
)
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
label_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"page_labels__label_id",
|
||||
distinct=True,
|
||||
filter=~Q(page_labels__label_id__isnull=True),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
project_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"projects__id",
|
||||
distinct=True,
|
||||
filter=~Q(projects__id=True),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
)
|
||||
.filter(project=True)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@@ -112,7 +137,7 @@ class PageViewSet(BaseViewSet):
|
||||
serializer.save()
|
||||
# capture the page transaction
|
||||
page_transaction.delay(request.data, None, serializer.data["id"])
|
||||
page = Page.objects.get(pk=serializer.data["id"])
|
||||
page = self.get_queryset().get(pk=serializer.data["id"])
|
||||
serializer = PageDetailSerializer(page)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -302,7 +327,7 @@ class PageViewSet(BaseViewSet):
|
||||
|
||||
# remove parent from all the children
|
||||
_ = Page.objects.filter(
|
||||
parent_id=pk, project_id=project_id, workspace__slug=slug
|
||||
parent_id=pk, projects__id=project_id, workspace__slug=slug
|
||||
).update(parent=None)
|
||||
|
||||
page.delete()
|
||||
|
||||
@@ -240,6 +240,12 @@ class ProjectViewSet(BaseViewSet):
|
||||
)
|
||||
).first()
|
||||
|
||||
if project is None:
|
||||
return Response(
|
||||
{"error": "Project does not exist"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
serializer = ProjectListSerializer(project)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
import re
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Q
|
||||
from django.db.models import Q, OuterRef, Subquery, Value, UUIDField, CharField
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from .base import BaseAPIView
|
||||
from plane.app.views.base import BaseAPIView
|
||||
from plane.db.models import (
|
||||
Workspace,
|
||||
Project,
|
||||
@@ -18,8 +21,8 @@ from plane.db.models import (
|
||||
Module,
|
||||
Page,
|
||||
IssueView,
|
||||
ProjectPage,
|
||||
)
|
||||
from plane.utils.issue_search import search_issues
|
||||
|
||||
|
||||
class GlobalSearchEndpoint(BaseAPIView):
|
||||
@@ -145,22 +148,51 @@ class GlobalSearchEndpoint(BaseAPIView):
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
|
||||
pages = Page.objects.filter(
|
||||
q,
|
||||
projects__project_projectmember__member=self.request.user,
|
||||
projects__project_projectmember__is_active=True,
|
||||
projects__archived_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
pages = (
|
||||
Page.objects.filter(
|
||||
q,
|
||||
projects__project_projectmember__member=self.request.user,
|
||||
projects__project_projectmember__is_active=True,
|
||||
projects__archived_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
.annotate(
|
||||
project_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"projects__id",
|
||||
distinct=True,
|
||||
filter=~Q(projects__id=True),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
project_identifiers=Coalesce(
|
||||
ArrayAgg(
|
||||
"projects__identifier",
|
||||
distinct=True,
|
||||
filter=~Q(projects__id=True),
|
||||
),
|
||||
Value([], output_field=ArrayField(CharField())),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if workspace_search == "false" and project_id:
|
||||
pages = pages.filter(project_id=project_id)
|
||||
project_subquery = ProjectPage.objects.filter(
|
||||
page_id=OuterRef("id"),
|
||||
project_id=project_id,
|
||||
).values_list("project_id", flat=True)[:1]
|
||||
|
||||
pages = pages.annotate(
|
||||
project_id=Subquery(project_subquery)
|
||||
).filter(project_id=project_id)
|
||||
|
||||
return pages.distinct().values(
|
||||
"name",
|
||||
"id",
|
||||
"project_id",
|
||||
"project__identifier",
|
||||
"project_ids",
|
||||
"project_identifiers",
|
||||
"workspace__slug",
|
||||
)
|
||||
|
||||
@@ -228,76 +260,3 @@ class GlobalSearchEndpoint(BaseAPIView):
|
||||
func = MODELS_MAPPER.get(model, None)
|
||||
results[model] = func(query, slug, project_id, workspace_search)
|
||||
return Response({"results": results}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class IssueSearchEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, project_id):
|
||||
query = request.query_params.get("search", False)
|
||||
workspace_search = request.query_params.get(
|
||||
"workspace_search", "false"
|
||||
)
|
||||
parent = request.query_params.get("parent", "false")
|
||||
issue_relation = request.query_params.get("issue_relation", "false")
|
||||
cycle = request.query_params.get("cycle", "false")
|
||||
module = request.query_params.get("module", False)
|
||||
sub_issue = request.query_params.get("sub_issue", "false")
|
||||
target_date = request.query_params.get("target_date", True)
|
||||
|
||||
issue_id = request.query_params.get("issue_id", False)
|
||||
|
||||
issues = Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__archived_at__isnull=True,
|
||||
)
|
||||
|
||||
if workspace_search == "false":
|
||||
issues = issues.filter(project_id=project_id)
|
||||
|
||||
if query:
|
||||
issues = search_issues(query, issues)
|
||||
|
||||
if parent == "true" and issue_id:
|
||||
issue = Issue.issue_objects.get(pk=issue_id)
|
||||
issues = issues.filter(
|
||||
~Q(pk=issue_id), ~Q(pk=issue.parent_id), ~Q(parent_id=issue_id)
|
||||
)
|
||||
if issue_relation == "true" and issue_id:
|
||||
issue = Issue.issue_objects.get(pk=issue_id)
|
||||
issues = issues.filter(
|
||||
~Q(pk=issue_id),
|
||||
~Q(issue_related__issue=issue),
|
||||
~Q(issue_relation__related_issue=issue),
|
||||
)
|
||||
if sub_issue == "true" and issue_id:
|
||||
issue = Issue.issue_objects.get(pk=issue_id)
|
||||
issues = issues.filter(~Q(pk=issue_id), parent__isnull=True)
|
||||
if issue.parent:
|
||||
issues = issues.filter(~Q(pk=issue.parent_id))
|
||||
|
||||
if cycle == "true":
|
||||
issues = issues.exclude(issue_cycle__isnull=False)
|
||||
|
||||
if module:
|
||||
issues = issues.exclude(issue_module__module=module)
|
||||
|
||||
if target_date == "none":
|
||||
issues = issues.filter(target_date__isnull=True)
|
||||
|
||||
return Response(
|
||||
issues.values(
|
||||
"name",
|
||||
"id",
|
||||
"start_date",
|
||||
"sequence_id",
|
||||
"project__name",
|
||||
"project__identifier",
|
||||
"project_id",
|
||||
"workspace__slug",
|
||||
"state__name",
|
||||
"state__group",
|
||||
"state__color",
|
||||
),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
# Python imports
|
||||
import re
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Q
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from .base import BaseAPIView
|
||||
from plane.db.models import (
|
||||
Workspace,
|
||||
Project,
|
||||
Issue,
|
||||
Cycle,
|
||||
Module,
|
||||
Page,
|
||||
IssueView,
|
||||
)
|
||||
from plane.utils.issue_search import search_issues
|
||||
|
||||
|
||||
class IssueSearchEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, project_id):
|
||||
query = request.query_params.get("search", False)
|
||||
workspace_search = request.query_params.get(
|
||||
"workspace_search", "false"
|
||||
)
|
||||
parent = request.query_params.get("parent", "false")
|
||||
issue_relation = request.query_params.get("issue_relation", "false")
|
||||
cycle = request.query_params.get("cycle", "false")
|
||||
module = request.query_params.get("module", False)
|
||||
sub_issue = request.query_params.get("sub_issue", "false")
|
||||
target_date = request.query_params.get("target_date", True)
|
||||
|
||||
issue_id = request.query_params.get("issue_id", False)
|
||||
|
||||
issues = Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__archived_at__isnull=True,
|
||||
)
|
||||
|
||||
if workspace_search == "false":
|
||||
issues = issues.filter(project_id=project_id)
|
||||
|
||||
if query:
|
||||
issues = search_issues(query, issues)
|
||||
|
||||
if parent == "true" and issue_id:
|
||||
issue = Issue.issue_objects.get(pk=issue_id)
|
||||
issues = issues.filter(
|
||||
~Q(pk=issue_id), ~Q(pk=issue.parent_id), ~Q(parent_id=issue_id)
|
||||
)
|
||||
if issue_relation == "true" and issue_id:
|
||||
issue = Issue.issue_objects.get(pk=issue_id)
|
||||
issues = issues.filter(
|
||||
~Q(pk=issue_id),
|
||||
~Q(issue_related__issue=issue),
|
||||
~Q(issue_relation__related_issue=issue),
|
||||
)
|
||||
if sub_issue == "true" and issue_id:
|
||||
issue = Issue.issue_objects.get(pk=issue_id)
|
||||
issues = issues.filter(~Q(pk=issue_id), parent__isnull=True)
|
||||
if issue.parent:
|
||||
issues = issues.filter(~Q(pk=issue.parent_id))
|
||||
|
||||
if cycle == "true":
|
||||
issues = issues.exclude(issue_cycle__isnull=False)
|
||||
|
||||
if module:
|
||||
issues = issues.exclude(issue_module__module=module)
|
||||
|
||||
if target_date == "none":
|
||||
issues = issues.filter(target_date__isnull=True)
|
||||
|
||||
return Response(
|
||||
issues.values(
|
||||
"name",
|
||||
"id",
|
||||
"start_date",
|
||||
"sequence_id",
|
||||
"project__name",
|
||||
"project__identifier",
|
||||
"project_id",
|
||||
"workspace__slug",
|
||||
"state__name",
|
||||
"state__group",
|
||||
"state__color",
|
||||
),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
@@ -33,10 +33,13 @@ AUTHENTICATION_ERROR_CODES = {
|
||||
"EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN": 5100,
|
||||
"EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP": 5102,
|
||||
# Oauth
|
||||
"OAUTH_NOT_CONFIGURED": 5104,
|
||||
"GOOGLE_NOT_CONFIGURED": 5105,
|
||||
"GITHUB_NOT_CONFIGURED": 5110,
|
||||
"GITLAB_NOT_CONFIGURED": 5111,
|
||||
"GOOGLE_OAUTH_PROVIDER_ERROR": 5115,
|
||||
"GITHUB_OAUTH_PROVIDER_ERROR": 5120,
|
||||
"GITLAB_OAUTH_PROVIDER_ERROR": 5121,
|
||||
# Reset Password
|
||||
"INVALID_PASSWORD_TOKEN": 5125,
|
||||
"EXPIRED_PASSWORD_TOKEN": 5130,
|
||||
|
||||
@@ -62,11 +62,7 @@ class OauthAdapter(Adapter):
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.RequestException:
|
||||
code = (
|
||||
"GOOGLE_OAUTH_PROVIDER_ERROR"
|
||||
if self.provider == "google"
|
||||
else "GITHUB_OAUTH_PROVIDER_ERROR"
|
||||
)
|
||||
code = self._provider_error_code()
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES[code],
|
||||
error_message=str(code),
|
||||
@@ -83,8 +79,12 @@ class OauthAdapter(Adapter):
|
||||
except requests.RequestException:
|
||||
if self.provider == "google":
|
||||
code = "GOOGLE_OAUTH_PROVIDER_ERROR"
|
||||
if self.provider == "github":
|
||||
elif self.provider == "github":
|
||||
code = "GITHUB_OAUTH_PROVIDER_ERROR"
|
||||
elif self.provider == "gitlab":
|
||||
code = "GITLAB_OAUTH_PROVIDER_ERROR"
|
||||
else:
|
||||
code = "OAUTH_NOT_CONFIGURED"
|
||||
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES[code],
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# Python imports
|
||||
import os
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import pytz
|
||||
|
||||
# Module imports
|
||||
from plane.authentication.adapter.oauth import OauthAdapter
|
||||
from plane.license.utils.instance_value import get_configuration_value
|
||||
from plane.authentication.adapter.error import (
|
||||
AuthenticationException,
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
)
|
||||
|
||||
|
||||
class GitLabOAuthProvider(OauthAdapter):
|
||||
|
||||
provider = "gitlab"
|
||||
scope = "read_user"
|
||||
|
||||
def __init__(self, request, code=None, state=None, callback=None):
|
||||
|
||||
GITLAB_CLIENT_ID, GITLAB_CLIENT_SECRET, GITLAB_HOST = (
|
||||
get_configuration_value(
|
||||
[
|
||||
{
|
||||
"key": "GITLAB_CLIENT_ID",
|
||||
"default": os.environ.get("GITLAB_CLIENT_ID"),
|
||||
},
|
||||
{
|
||||
"key": "GITLAB_CLIENT_SECRET",
|
||||
"default": os.environ.get("GITLAB_CLIENT_SECRET"),
|
||||
},
|
||||
{
|
||||
"key": "GITLAB_HOST",
|
||||
"default": os.environ.get(
|
||||
"GITLAB_HOST", "https://gitlab.com"
|
||||
),
|
||||
},
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
self.host = GITLAB_HOST
|
||||
self.token_url = f"{self.host}/oauth/token"
|
||||
self.userinfo_url = f"{self.host}/api/v4/user"
|
||||
|
||||
if not (GITLAB_CLIENT_ID and GITLAB_CLIENT_SECRET and GITLAB_HOST):
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["GITLAB_NOT_CONFIGURED"],
|
||||
error_message="GITLAB_NOT_CONFIGURED",
|
||||
)
|
||||
|
||||
client_id = GITLAB_CLIENT_ID
|
||||
client_secret = GITLAB_CLIENT_SECRET
|
||||
|
||||
redirect_uri = f"""{"https" if request.is_secure() else "http"}://{request.get_host()}/auth/gitlab/callback/"""
|
||||
url_params = {
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": self.scope,
|
||||
"state": state,
|
||||
}
|
||||
auth_url = f"{self.host}/oauth/authorize?{urlencode(url_params)}"
|
||||
super().__init__(
|
||||
request,
|
||||
self.provider,
|
||||
client_id,
|
||||
self.scope,
|
||||
redirect_uri,
|
||||
auth_url,
|
||||
self.token_url,
|
||||
self.userinfo_url,
|
||||
client_secret,
|
||||
code,
|
||||
callback=callback,
|
||||
)
|
||||
|
||||
def set_token_data(self):
|
||||
data = {
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"code": self.code,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"grant_type": "authorization_code",
|
||||
}
|
||||
token_response = self.get_user_token(
|
||||
data=data, headers={"Accept": "application/json"}
|
||||
)
|
||||
super().set_token_data(
|
||||
{
|
||||
"access_token": token_response.get("access_token"),
|
||||
"refresh_token": token_response.get("refresh_token", None),
|
||||
"access_token_expired_at": (
|
||||
datetime.fromtimestamp(
|
||||
token_response.get("created_at")
|
||||
+ token_response.get("expires_in"),
|
||||
tz=pytz.utc,
|
||||
)
|
||||
if token_response.get("expires_in")
|
||||
else None
|
||||
),
|
||||
"refresh_token_expired_at": (
|
||||
datetime.fromtimestamp(
|
||||
token_response.get("refresh_token_expired_at"),
|
||||
tz=pytz.utc,
|
||||
)
|
||||
if token_response.get("refresh_token_expired_at")
|
||||
else None
|
||||
),
|
||||
"id_token": token_response.get("id_token", ""),
|
||||
}
|
||||
)
|
||||
|
||||
def set_user_data(self):
|
||||
user_info_response = self.get_user_response()
|
||||
email = user_info_response.get("email")
|
||||
super().set_user_data(
|
||||
{
|
||||
"email": email,
|
||||
"user": {
|
||||
"provider_id": user_info_response.get("id"),
|
||||
"email": email,
|
||||
"avatar": user_info_response.get("avatar_url"),
|
||||
"first_name": user_info_response.get("name"),
|
||||
"last_name": user_info_response.get("family_name"),
|
||||
"is_password_autoset": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -8,6 +8,8 @@ from .views import (
|
||||
ChangePasswordEndpoint,
|
||||
# App
|
||||
EmailCheckEndpoint,
|
||||
GitLabCallbackEndpoint,
|
||||
GitLabOauthInitiateEndpoint,
|
||||
GitHubCallbackEndpoint,
|
||||
GitHubOauthInitiateEndpoint,
|
||||
GoogleCallbackEndpoint,
|
||||
@@ -22,6 +24,8 @@ from .views import (
|
||||
ResetPasswordSpaceEndpoint,
|
||||
# Space
|
||||
EmailCheckSpaceEndpoint,
|
||||
GitLabCallbackSpaceEndpoint,
|
||||
GitLabOauthInitiateSpaceEndpoint,
|
||||
GitHubCallbackSpaceEndpoint,
|
||||
GitHubOauthInitiateSpaceEndpoint,
|
||||
GoogleCallbackSpaceEndpoint,
|
||||
@@ -151,6 +155,27 @@ urlpatterns = [
|
||||
GitHubCallbackSpaceEndpoint.as_view(),
|
||||
name="github-callback",
|
||||
),
|
||||
## Gitlab Oauth
|
||||
path(
|
||||
"gitlab/",
|
||||
GitLabOauthInitiateEndpoint.as_view(),
|
||||
name="gitlab-initiate",
|
||||
),
|
||||
path(
|
||||
"gitlab/callback/",
|
||||
GitLabCallbackEndpoint.as_view(),
|
||||
name="gitlab-callback",
|
||||
),
|
||||
path(
|
||||
"spaces/gitlab/",
|
||||
GitLabOauthInitiateSpaceEndpoint.as_view(),
|
||||
name="gitlab-initiate",
|
||||
),
|
||||
path(
|
||||
"spaces/gitlab/callback/",
|
||||
GitLabCallbackSpaceEndpoint.as_view(),
|
||||
name="gitlab-callback",
|
||||
),
|
||||
# Email Check
|
||||
path(
|
||||
"email-check/",
|
||||
|
||||
@@ -14,6 +14,10 @@ from .app.github import (
|
||||
GitHubCallbackEndpoint,
|
||||
GitHubOauthInitiateEndpoint,
|
||||
)
|
||||
from .app.gitlab import (
|
||||
GitLabCallbackEndpoint,
|
||||
GitLabOauthInitiateEndpoint,
|
||||
)
|
||||
from .app.google import (
|
||||
GoogleCallbackEndpoint,
|
||||
GoogleOauthInitiateEndpoint,
|
||||
@@ -34,6 +38,11 @@ from .space.github import (
|
||||
GitHubOauthInitiateSpaceEndpoint,
|
||||
)
|
||||
|
||||
from .space.gitlab import (
|
||||
GitLabCallbackSpaceEndpoint,
|
||||
GitLabOauthInitiateSpaceEndpoint,
|
||||
)
|
||||
|
||||
from .space.google import (
|
||||
GoogleCallbackSpaceEndpoint,
|
||||
GoogleOauthInitiateSpaceEndpoint,
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import uuid
|
||||
from urllib.parse import urlencode, urljoin
|
||||
|
||||
# Django import
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.views import View
|
||||
|
||||
# Module imports
|
||||
from plane.authentication.provider.oauth.gitlab import GitLabOAuthProvider
|
||||
from plane.authentication.utils.login import user_login
|
||||
from plane.authentication.utils.redirection_path import get_redirection_path
|
||||
from plane.authentication.utils.user_auth_workflow import (
|
||||
post_user_auth_workflow,
|
||||
)
|
||||
from plane.license.models import Instance
|
||||
from plane.authentication.utils.host import base_host
|
||||
from plane.authentication.adapter.error import (
|
||||
AuthenticationException,
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
)
|
||||
|
||||
|
||||
class GitLabOauthInitiateEndpoint(View):
|
||||
|
||||
def get(self, request):
|
||||
# Get host and next path
|
||||
request.session["host"] = base_host(request=request, is_app=True)
|
||||
next_path = request.GET.get("next_path")
|
||||
if next_path:
|
||||
request.session["next_path"] = str(next_path)
|
||||
|
||||
# Check instance configuration
|
||||
instance = Instance.objects.first()
|
||||
if instance is None or not instance.is_setup_done:
|
||||
exc = AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES[
|
||||
"INSTANCE_NOT_CONFIGURED"
|
||||
],
|
||||
error_message="INSTANCE_NOT_CONFIGURED",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(next_path)
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True),
|
||||
"?" + urlencode(params),
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
try:
|
||||
state = uuid.uuid4().hex
|
||||
provider = GitLabOAuthProvider(request=request, state=state)
|
||||
request.session["state"] = state
|
||||
auth_url = provider.get_auth_url()
|
||||
return HttpResponseRedirect(auth_url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(next_path)
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True),
|
||||
"?" + urlencode(params),
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
|
||||
class GitLabCallbackEndpoint(View):
|
||||
|
||||
def get(self, request):
|
||||
code = request.GET.get("code")
|
||||
state = request.GET.get("state")
|
||||
base_host = request.session.get("host")
|
||||
next_path = request.session.get("next_path")
|
||||
|
||||
if state != request.session.get("state", ""):
|
||||
exc = AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES[
|
||||
"GITLAB_OAUTH_PROVIDER_ERROR"
|
||||
],
|
||||
error_message="GITLAB_OAUTH_PROVIDER_ERROR",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(next_path)
|
||||
url = urljoin(
|
||||
base_host,
|
||||
"?" + urlencode(params),
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
if not code:
|
||||
exc = AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES[
|
||||
"GITLAB_OAUTH_PROVIDER_ERROR"
|
||||
],
|
||||
error_message="GITLAB_OAUTH_PROVIDER_ERROR",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(next_path)
|
||||
url = urljoin(
|
||||
base_host,
|
||||
"?" + urlencode(params),
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
try:
|
||||
provider = GitLabOAuthProvider(
|
||||
request=request,
|
||||
code=code,
|
||||
callback=post_user_auth_workflow,
|
||||
)
|
||||
user = provider.authenticate()
|
||||
# Login the user and record his device info
|
||||
user_login(request=request, user=user, is_app=True)
|
||||
# Get the redirection path
|
||||
if next_path:
|
||||
path = next_path
|
||||
else:
|
||||
path = get_redirection_path(user=user)
|
||||
# redirect to referer path
|
||||
url = urljoin(base_host, path)
|
||||
return HttpResponseRedirect(url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(next_path)
|
||||
url = urljoin(
|
||||
base_host,
|
||||
"?" + urlencode(params),
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
@@ -0,0 +1,109 @@
|
||||
# Python imports
|
||||
import uuid
|
||||
from urllib.parse import urlencode
|
||||
|
||||
# Django import
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.views import View
|
||||
|
||||
# Module imports
|
||||
from plane.authentication.provider.oauth.gitlab import GitLabOAuthProvider
|
||||
from plane.authentication.utils.login import user_login
|
||||
from plane.license.models import Instance
|
||||
from plane.authentication.utils.host import base_host
|
||||
from plane.authentication.adapter.error import (
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
AuthenticationException,
|
||||
)
|
||||
|
||||
|
||||
class GitLabOauthInitiateSpaceEndpoint(View):
|
||||
|
||||
def get(self, request):
|
||||
# Get host and next path
|
||||
request.session["host"] = base_host(request=request, is_space=True)
|
||||
next_path = request.GET.get("next_path")
|
||||
if next_path:
|
||||
request.session["next_path"] = str(next_path)
|
||||
|
||||
# Check instance configuration
|
||||
instance = Instance.objects.first()
|
||||
if instance is None or not instance.is_setup_done:
|
||||
exc = AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES[
|
||||
"INSTANCE_NOT_CONFIGURED"
|
||||
],
|
||||
error_message="INSTANCE_NOT_CONFIGURED",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
try:
|
||||
state = uuid.uuid4().hex
|
||||
provider = GitLabOAuthProvider(request=request, state=state)
|
||||
request.session["state"] = state
|
||||
auth_url = provider.get_auth_url()
|
||||
return HttpResponseRedirect(auth_url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
|
||||
class GitLabCallbackSpaceEndpoint(View):
|
||||
|
||||
def get(self, request):
|
||||
code = request.GET.get("code")
|
||||
state = request.GET.get("state")
|
||||
base_host = request.session.get("host")
|
||||
next_path = request.session.get("next_path")
|
||||
|
||||
if state != request.session.get("state", ""):
|
||||
exc = AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES[
|
||||
"GITLAB_OAUTH_PROVIDER_ERROR"
|
||||
],
|
||||
error_message="GITLAB_OAUTH_PROVIDER_ERROR",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
if not code:
|
||||
exc = AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES[
|
||||
"GITLAB_OAUTH_PROVIDER_ERROR"
|
||||
],
|
||||
error_message="GITLAB_OAUTH_PROVIDER_ERROR",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
try:
|
||||
provider = GitLabOAuthProvider(
|
||||
request=request,
|
||||
code=code,
|
||||
)
|
||||
user = provider.authenticate()
|
||||
# Login the user and record his device info
|
||||
user_login(request=request, user=user, is_space=True)
|
||||
# Process workspace and project invitations
|
||||
# redirect to referer path
|
||||
url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
|
||||
return HttpResponseRedirect(url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
return HttpResponseRedirect(url)
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.11 on 2024-06-03 17:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0068_remove_pagelabel_project_remove_pagelog_project_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='account',
|
||||
name='provider',
|
||||
field=models.CharField(choices=[('google', 'Google'), ('github', 'Github'), ('gitlab', 'GitLab')]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='socialloginconnection',
|
||||
name='medium',
|
||||
field=models.CharField(choices=[('Google', 'google'), ('Github', 'github'), ('GitLab', 'gitlab'), ('Jira', 'jira')], default=None, max_length=20),
|
||||
),
|
||||
]
|
||||
@@ -10,7 +10,7 @@ from .base import BaseModel
|
||||
class SocialLoginConnection(BaseModel):
|
||||
medium = models.CharField(
|
||||
max_length=20,
|
||||
choices=(("Google", "google"), ("Github", "github"), ("Jira", "jira")),
|
||||
choices=(("Google", "google"), ("Github", "github"), ("GitLab", "gitlab"), ("Jira", "jira")),
|
||||
default=None,
|
||||
)
|
||||
last_login_at = models.DateTimeField(default=timezone.now, null=True)
|
||||
|
||||
@@ -182,7 +182,7 @@ class Account(TimeAuditModel):
|
||||
)
|
||||
provider_account_id = models.CharField(max_length=255)
|
||||
provider = models.CharField(
|
||||
choices=(("google", "Google"), ("github", "Github")),
|
||||
choices=(("google", "Google"), ("github", "Github"), ("gitlab", "GitLab")),
|
||||
)
|
||||
access_token = models.TextField()
|
||||
access_token_expired_at = models.DateTimeField(null=True)
|
||||
|
||||
@@ -54,6 +54,7 @@ class InstanceEndpoint(BaseAPIView):
|
||||
IS_GOOGLE_ENABLED,
|
||||
IS_GITHUB_ENABLED,
|
||||
GITHUB_APP_NAME,
|
||||
IS_GITLAB_ENABLED,
|
||||
EMAIL_HOST,
|
||||
ENABLE_MAGIC_LINK_LOGIN,
|
||||
ENABLE_EMAIL_PASSWORD,
|
||||
@@ -76,6 +77,10 @@ class InstanceEndpoint(BaseAPIView):
|
||||
"key": "GITHUB_APP_NAME",
|
||||
"default": os.environ.get("GITHUB_APP_NAME", ""),
|
||||
},
|
||||
{
|
||||
"key": "IS_GITLAB_ENABLED",
|
||||
"default": os.environ.get("IS_GITLAB_ENABLED", "0"),
|
||||
},
|
||||
{
|
||||
"key": "EMAIL_HOST",
|
||||
"default": os.environ.get("EMAIL_HOST", ""),
|
||||
@@ -115,6 +120,7 @@ class InstanceEndpoint(BaseAPIView):
|
||||
# Authentication
|
||||
data["is_google_enabled"] = IS_GOOGLE_ENABLED == "1"
|
||||
data["is_github_enabled"] = IS_GITHUB_ENABLED == "1"
|
||||
data["is_gitlab_enabled"] = IS_GITLAB_ENABLED == "1"
|
||||
data["is_magic_login_enabled"] = ENABLE_MAGIC_LINK_LOGIN == "1"
|
||||
data["is_email_password_enabled"] = ENABLE_EMAIL_PASSWORD == "1"
|
||||
|
||||
|
||||
@@ -65,6 +65,24 @@ class Command(BaseCommand):
|
||||
"category": "GITHUB",
|
||||
"is_encrypted": True,
|
||||
},
|
||||
{
|
||||
"key": "GITLAB_HOST",
|
||||
"value": os.environ.get("GITLAB_HOST"),
|
||||
"category": "GITLAB",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "GITLAB_CLIENT_ID",
|
||||
"value": os.environ.get("GITLAB_CLIENT_ID"),
|
||||
"category": "GITLAB",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "GITLAB_CLIENT_SECRET",
|
||||
"value": os.environ.get("GITLAB_CLIENT_SECRET"),
|
||||
"category": "GITLAB",
|
||||
"is_encrypted": True,
|
||||
},
|
||||
{
|
||||
"key": "EMAIL_HOST",
|
||||
"value": os.environ.get("EMAIL_HOST", ""),
|
||||
@@ -151,7 +169,7 @@ class Command(BaseCommand):
|
||||
)
|
||||
)
|
||||
|
||||
keys = ["IS_GOOGLE_ENABLED", "IS_GITHUB_ENABLED"]
|
||||
keys = ["IS_GOOGLE_ENABLED", "IS_GITHUB_ENABLED", "IS_GITLAB_ENABLED"]
|
||||
if not InstanceConfiguration.objects.filter(key__in=keys).exists():
|
||||
for key in keys:
|
||||
if key == "IS_GOOGLE_ENABLED":
|
||||
@@ -222,6 +240,46 @@ class Command(BaseCommand):
|
||||
f"{key} loaded with value from environment variable."
|
||||
)
|
||||
)
|
||||
if key == "IS_GITLAB_ENABLED":
|
||||
GITLAB_HOST, GITLAB_CLIENT_ID, GITLAB_CLIENT_SECRET = (
|
||||
get_configuration_value(
|
||||
[
|
||||
{
|
||||
"key": "GITLAB_HOST",
|
||||
"default": os.environ.get(
|
||||
"GITLAB_HOST", "https://gitlab.com"
|
||||
),
|
||||
},
|
||||
{
|
||||
"key": "GITLAB_CLIENT_ID",
|
||||
"default": os.environ.get(
|
||||
"GITLAB_CLIENT_ID", ""
|
||||
),
|
||||
},
|
||||
{
|
||||
"key": "GITLAB_CLIENT_SECRET",
|
||||
"default": os.environ.get(
|
||||
"GITLAB_CLIENT_SECRET", ""
|
||||
),
|
||||
},
|
||||
]
|
||||
)
|
||||
)
|
||||
if bool(GITLAB_HOST) and bool(GITLAB_CLIENT_ID) and bool(GITLAB_CLIENT_SECRET):
|
||||
value = "1"
|
||||
else:
|
||||
value = "0"
|
||||
InstanceConfiguration.objects.create(
|
||||
key="IS_GITLAB_ENABLED",
|
||||
value=value,
|
||||
category="AUTHENTICATION",
|
||||
is_encrypted=False,
|
||||
)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"{key} loaded with value from environment variable."
|
||||
)
|
||||
)
|
||||
else:
|
||||
for key in keys:
|
||||
self.stdout.write(
|
||||
|
||||
@@ -2,7 +2,20 @@
|
||||
import json
|
||||
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db.models import Exists, F, Func, OuterRef, Q, Prefetch
|
||||
from django.db.models import (
|
||||
Exists,
|
||||
F,
|
||||
Func,
|
||||
OuterRef,
|
||||
Q,
|
||||
Prefetch,
|
||||
Case,
|
||||
When,
|
||||
CharField,
|
||||
IntegerField,
|
||||
Value,
|
||||
Max,
|
||||
)
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
@@ -30,19 +43,11 @@ from plane.db.models import (
|
||||
DeployBoard,
|
||||
IssueVote,
|
||||
ProjectPublicMember,
|
||||
State,
|
||||
Label,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.grouper import (
|
||||
issue_group_values,
|
||||
issue_on_results,
|
||||
issue_queryset_grouper,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.order_queryset import order_issue_queryset
|
||||
from plane.utils.paginator import (
|
||||
GroupedOffsetPaginator,
|
||||
SubGroupedOffsetPaginator,
|
||||
)
|
||||
|
||||
# Module imports
|
||||
from .base import BaseAPIView, BaseViewSet
|
||||
@@ -516,24 +521,32 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
|
||||
]
|
||||
|
||||
def get(self, request, anchor):
|
||||
if not DeployBoard.objects.filter(
|
||||
deploy_board = DeployBoard.objects.filter(
|
||||
anchor=anchor, entity_name="project"
|
||||
).exists():
|
||||
).first()
|
||||
if not deploy_board:
|
||||
return Response(
|
||||
{"error": "Project is not published"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
project_deploy_board = DeployBoard.objects.get(
|
||||
anchor=anchor, entity_name="project"
|
||||
)
|
||||
|
||||
project_id = project_deploy_board.entity_identifier
|
||||
slug = project_deploy_board.workspace.slug
|
||||
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
|
||||
# Custom ordering for priority and state
|
||||
priority_order = ["urgent", "high", "medium", "low", "none"]
|
||||
state_order = [
|
||||
"backlog",
|
||||
"unstarted",
|
||||
"started",
|
||||
"completed",
|
||||
"cancelled",
|
||||
]
|
||||
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
|
||||
project_id = deploy_board.entity_identifier
|
||||
slug = deploy_board.workspace.slug
|
||||
|
||||
issue_queryset = (
|
||||
Issue.issue_objects.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
@@ -543,8 +556,8 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.filter(project_id=project_deploy_board.project_id)
|
||||
.filter(workspace_id=project_deploy_board.workspace_id)
|
||||
.filter(project_id=project_id)
|
||||
.filter(workspace__slug=slug)
|
||||
.select_related("project", "workspace", "state", "parent")
|
||||
.prefetch_related("assignees", "labels")
|
||||
.prefetch_related(
|
||||
@@ -561,6 +574,7 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
|
||||
)
|
||||
.filter(**filters)
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(module_id=F("issue_module__module_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
@@ -575,118 +589,107 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
|
||||
# Priority Ordering
|
||||
if order_by_param == "priority" or order_by_param == "-priority":
|
||||
priority_order = (
|
||||
priority_order
|
||||
if order_by_param == "priority"
|
||||
else priority_order[::-1]
|
||||
)
|
||||
).distinct()
|
||||
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
|
||||
issue_queryset = self.get_queryset().filter(**filters)
|
||||
|
||||
# Issue queryset
|
||||
issue_queryset, order_by_param = order_issue_queryset(
|
||||
issue_queryset=issue_queryset,
|
||||
order_by_param=order_by_param,
|
||||
)
|
||||
|
||||
# Group by
|
||||
group_by = request.GET.get("group_by", False)
|
||||
sub_group_by = request.GET.get("sub_group_by", False)
|
||||
|
||||
# issue queryset
|
||||
issue_queryset = issue_queryset_grouper(
|
||||
queryset=issue_queryset,
|
||||
group_by=group_by,
|
||||
sub_group_by=sub_group_by,
|
||||
)
|
||||
|
||||
if group_by:
|
||||
# Check group and sub group value paginate
|
||||
if sub_group_by:
|
||||
if group_by == sub_group_by:
|
||||
return Response(
|
||||
{
|
||||
"error": "Group by and sub group by cannot have same parameters"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
else:
|
||||
# group and sub group pagination
|
||||
return self.paginate(
|
||||
request=request,
|
||||
order_by=order_by_param,
|
||||
queryset=issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by,
|
||||
issues=issues,
|
||||
sub_group_by=sub_group_by,
|
||||
),
|
||||
paginator_cls=SubGroupedOffsetPaginator,
|
||||
group_by_fields=issue_group_values(
|
||||
field=group_by,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
filters=filters,
|
||||
),
|
||||
sub_group_by_fields=issue_group_values(
|
||||
field=sub_group_by,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
filters=filters,
|
||||
),
|
||||
group_by_field_name=group_by,
|
||||
sub_group_by_field_name=sub_group_by,
|
||||
count_filter=Q(
|
||||
Q(issue_inbox__status=1)
|
||||
| Q(issue_inbox__status=-1)
|
||||
| Q(issue_inbox__status=2)
|
||||
| Q(issue_inbox__isnull=True),
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
# Group Paginate
|
||||
else:
|
||||
# Group paginate
|
||||
return self.paginate(
|
||||
request=request,
|
||||
order_by=order_by_param,
|
||||
queryset=issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by,
|
||||
issues=issues,
|
||||
sub_group_by=sub_group_by,
|
||||
),
|
||||
paginator_cls=GroupedOffsetPaginator,
|
||||
group_by_fields=issue_group_values(
|
||||
field=group_by,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
filters=filters,
|
||||
),
|
||||
group_by_field_name=group_by,
|
||||
count_filter=Q(
|
||||
Q(issue_inbox__status=1)
|
||||
| Q(issue_inbox__status=-1)
|
||||
| Q(issue_inbox__status=2)
|
||||
| Q(issue_inbox__isnull=True),
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
issue_queryset = issue_queryset.annotate(
|
||||
priority_order=Case(
|
||||
*[
|
||||
When(priority=p, then=Value(i))
|
||||
for i, p in enumerate(priority_order)
|
||||
],
|
||||
output_field=CharField(),
|
||||
)
|
||||
).order_by("priority_order")
|
||||
|
||||
# State Ordering
|
||||
elif order_by_param in [
|
||||
"state__name",
|
||||
"state__group",
|
||||
"-state__name",
|
||||
"-state__group",
|
||||
]:
|
||||
state_order = (
|
||||
state_order
|
||||
if order_by_param in ["state__name", "state__group"]
|
||||
else state_order[::-1]
|
||||
)
|
||||
issue_queryset = issue_queryset.annotate(
|
||||
state_order=Case(
|
||||
*[
|
||||
When(state__group=state_group, then=Value(i))
|
||||
for i, state_group in enumerate(state_order)
|
||||
],
|
||||
default=Value(len(state_order)),
|
||||
output_field=CharField(),
|
||||
)
|
||||
).order_by("state_order")
|
||||
# assignee and label ordering
|
||||
elif order_by_param in [
|
||||
"labels__name",
|
||||
"-labels__name",
|
||||
"assignees__first_name",
|
||||
"-assignees__first_name",
|
||||
]:
|
||||
issue_queryset = issue_queryset.annotate(
|
||||
max_values=Max(
|
||||
order_by_param[1::]
|
||||
if order_by_param.startswith("-")
|
||||
else order_by_param
|
||||
)
|
||||
).order_by(
|
||||
"-max_values"
|
||||
if order_by_param.startswith("-")
|
||||
else "max_values"
|
||||
)
|
||||
else:
|
||||
# List Paginate
|
||||
return self.paginate(
|
||||
order_by=order_by_param,
|
||||
request=request,
|
||||
queryset=issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
issue_queryset = issue_queryset.order_by(order_by_param)
|
||||
|
||||
issues = IssuePublicSerializer(issue_queryset, many=True).data
|
||||
|
||||
state_group_order = [
|
||||
"backlog",
|
||||
"unstarted",
|
||||
"started",
|
||||
"completed",
|
||||
"cancelled",
|
||||
]
|
||||
|
||||
states = (
|
||||
State.objects.filter(
|
||||
~Q(name="Triage"),
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(
|
||||
custom_order=Case(
|
||||
*[
|
||||
When(group=value, then=Value(index))
|
||||
for index, value in enumerate(state_group_order)
|
||||
],
|
||||
default=Value(len(state_group_order)),
|
||||
output_field=IntegerField(),
|
||||
),
|
||||
)
|
||||
.values("name", "group", "color", "id")
|
||||
.order_by("custom_order", "sequence")
|
||||
)
|
||||
|
||||
labels = Label.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
).values("id", "name", "color", "parent")
|
||||
|
||||
return Response(
|
||||
{
|
||||
"issues": issues,
|
||||
"states": states,
|
||||
"labels": labels,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@@ -37,7 +37,9 @@ class WorkspaceProjectDeployBoardEndpoint(BaseAPIView):
|
||||
]
|
||||
|
||||
def get(self, request, anchor):
|
||||
deploy_board = DeployBoard.objects.filter(anchor=anchor, entity_name="project").values_list
|
||||
deploy_board = DeployBoard.objects.filter(
|
||||
anchor=anchor, entity_name="project"
|
||||
).values_list
|
||||
projects = (
|
||||
Project.objects.filter(workspace=deploy_board.workspace)
|
||||
.annotate(
|
||||
|
||||
@@ -16,7 +16,7 @@ def log_exception(e):
|
||||
|
||||
if settings.DEBUG:
|
||||
# Print the traceback if in debug mode
|
||||
traceback.print_exc(e)
|
||||
print(traceback.format_exc())
|
||||
|
||||
# Capture in sentry if configured
|
||||
capture_exception(e)
|
||||
|
||||
@@ -187,11 +187,11 @@ class OffsetPaginator:
|
||||
|
||||
class GroupedOffsetPaginator(OffsetPaginator):
|
||||
|
||||
# Field mappers
|
||||
# Field mappers - list m2m fields here
|
||||
FIELD_MAPPER = {
|
||||
"labels__id": "label_ids",
|
||||
"assignees__id": "assignee_ids",
|
||||
"modules__id": "module_ids",
|
||||
"issue_module__module_id": "module_ids",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
@@ -205,8 +205,12 @@ class GroupedOffsetPaginator(OffsetPaginator):
|
||||
):
|
||||
# Initiate the parent class for all the parameters
|
||||
super().__init__(queryset, *args, **kwargs)
|
||||
|
||||
# Set the group by field name
|
||||
self.group_by_field_name = group_by_field_name
|
||||
# Set the group by fields
|
||||
self.group_by_fields = group_by_fields
|
||||
# Set the count filter - this are extra filters that need to be passed to calculate the counts with the filters
|
||||
self.count_filter = count_filter
|
||||
|
||||
def get_result(self, limit=50, cursor=None):
|
||||
@@ -224,8 +228,11 @@ class GroupedOffsetPaginator(OffsetPaginator):
|
||||
offset = cursor.offset * cursor.value
|
||||
stop = offset + (cursor.value or limit) + 1
|
||||
|
||||
# Check if the offset is greater than the max offset
|
||||
if self.max_offset is not None and offset >= self.max_offset:
|
||||
raise BadPaginationError("Pagination offset too large")
|
||||
|
||||
# Check if the offset is less than 0
|
||||
if offset < 0:
|
||||
raise BadPaginationError("Pagination offset cannot be negative")
|
||||
|
||||
@@ -269,6 +276,8 @@ class GroupedOffsetPaginator(OffsetPaginator):
|
||||
False,
|
||||
queryset.filter(row_number__gte=stop).exists(),
|
||||
)
|
||||
|
||||
# Add previous cursors
|
||||
prev_cursor = Cursor(
|
||||
limit,
|
||||
page - 1,
|
||||
@@ -305,7 +314,7 @@ class GroupedOffsetPaginator(OffsetPaginator):
|
||||
)
|
||||
|
||||
def __get_total_queryset(self):
|
||||
# Get total queryset
|
||||
# Get total items for each group
|
||||
return (
|
||||
self.queryset.values(self.group_by_field_name)
|
||||
.annotate(
|
||||
@@ -328,7 +337,6 @@ class GroupedOffsetPaginator(OffsetPaginator):
|
||||
)
|
||||
+ (1 if group.get("count") == 0 else group.get("count"))
|
||||
)
|
||||
|
||||
return total_group_dict
|
||||
|
||||
def __get_field_dict(self):
|
||||
@@ -353,7 +361,7 @@ class GroupedOffsetPaginator(OffsetPaginator):
|
||||
# Grouping for m2m values
|
||||
total_group_dict = self.__get_total_dict()
|
||||
|
||||
# Preparing a dict to keep track of group IDs associated with each label ID
|
||||
# Preparing a dict to keep track of group IDs associated with each entity ID
|
||||
result_group_mapping = defaultdict(set)
|
||||
# Preparing a dict to group result by group ID
|
||||
grouped_by_field_name = defaultdict(list)
|
||||
@@ -390,7 +398,7 @@ class GroupedOffsetPaginator(OffsetPaginator):
|
||||
return processed_results
|
||||
|
||||
def __query_grouper(self, results):
|
||||
# Grouping for single values
|
||||
# Grouping for values that are not m2m
|
||||
processed_results = self.__get_field_dict()
|
||||
for result in results:
|
||||
group_value = str(result.get(self.group_by_field_name))
|
||||
@@ -411,10 +419,11 @@ class GroupedOffsetPaginator(OffsetPaginator):
|
||||
|
||||
|
||||
class SubGroupedOffsetPaginator(OffsetPaginator):
|
||||
# Field mappers this are the fields that are m2m
|
||||
FIELD_MAPPER = {
|
||||
"labels__id": "label_ids",
|
||||
"assignees__id": "assignee_ids",
|
||||
"modules__id": "module_ids",
|
||||
"issue_module__module_id": "module_ids",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
@@ -428,11 +437,18 @@ class SubGroupedOffsetPaginator(OffsetPaginator):
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
# Initiate the parent class for all the parameters
|
||||
super().__init__(queryset, *args, **kwargs)
|
||||
|
||||
# Set the group by field name
|
||||
self.group_by_field_name = group_by_field_name
|
||||
self.group_by_fields = group_by_fields
|
||||
|
||||
# Set the sub group by field name
|
||||
self.sub_group_by_field_name = sub_group_by_field_name
|
||||
self.sub_group_by_fields = sub_group_by_fields
|
||||
|
||||
# Set the count filter - this are extra filters that need to be passed to calculate the counts with the filters
|
||||
self.count_filter = count_filter
|
||||
|
||||
def get_result(self, limit=30, cursor=None):
|
||||
@@ -441,13 +457,19 @@ class SubGroupedOffsetPaginator(OffsetPaginator):
|
||||
if cursor is None:
|
||||
cursor = Cursor(0, 0, 0)
|
||||
|
||||
# get the minimum value
|
||||
limit = min(limit, self.max_limit)
|
||||
|
||||
# Adjust the initial offset and stop based on the cursor and limit
|
||||
queryset = self.queryset
|
||||
|
||||
# the current page
|
||||
page = cursor.offset
|
||||
|
||||
# the offset
|
||||
offset = cursor.offset * cursor.value
|
||||
|
||||
# the stop
|
||||
stop = offset + (cursor.value or limit) + 1
|
||||
|
||||
if self.max_offset is not None and offset >= self.max_offset:
|
||||
@@ -496,6 +518,8 @@ class SubGroupedOffsetPaginator(OffsetPaginator):
|
||||
False,
|
||||
queryset.filter(row_number__gte=stop).exists(),
|
||||
)
|
||||
|
||||
# Add previous cursors
|
||||
prev_cursor = Cursor(
|
||||
limit,
|
||||
page - 1,
|
||||
@@ -579,19 +603,24 @@ class SubGroupedOffsetPaginator(OffsetPaginator):
|
||||
subgroup = str(item[self.sub_group_by_field_name])
|
||||
count = item["count"]
|
||||
|
||||
# Create a dictionary of group and sub group
|
||||
if group not in total_sub_group_dict:
|
||||
total_sub_group_dict[str(group)] = {}
|
||||
|
||||
# Create a dictionary of sub group
|
||||
if subgroup not in total_sub_group_dict[group]:
|
||||
total_sub_group_dict[str(group)][str(subgroup)] = {}
|
||||
|
||||
# Create a nested dictionary of group and sub group
|
||||
total_sub_group_dict[group][subgroup] = count
|
||||
|
||||
return total_group_dict, total_sub_group_dict
|
||||
|
||||
def __get_field_dict(self):
|
||||
# Create a field dictionary
|
||||
total_group_dict, total_sub_group_dict = self.__get_total_dict()
|
||||
|
||||
# Create a dictionary of group and sub group
|
||||
return {
|
||||
str(group): {
|
||||
"results": {
|
||||
@@ -621,7 +650,6 @@ class SubGroupedOffsetPaginator(OffsetPaginator):
|
||||
result_id = result["id"]
|
||||
group_id = result[self.group_by_field_name]
|
||||
result_group_mapping[str(result_id)].add(str(group_id))
|
||||
|
||||
# Use the same calculation for the sub group
|
||||
if self.sub_group_by_field_name in self.FIELD_MAPPER:
|
||||
for result in results:
|
||||
@@ -635,6 +663,9 @@ class SubGroupedOffsetPaginator(OffsetPaginator):
|
||||
group_value = str(result.get(self.group_by_field_name))
|
||||
# Get the sub group value
|
||||
sub_group_value = str(result.get(self.sub_group_by_field_name))
|
||||
# Check if the group value is in the processed results
|
||||
result_id = result["id"]
|
||||
|
||||
if (
|
||||
group_value in processed_results
|
||||
and sub_group_value
|
||||
@@ -647,12 +678,14 @@ class SubGroupedOffsetPaginator(OffsetPaginator):
|
||||
[] if "None" in group_ids else group_ids
|
||||
)
|
||||
if self.sub_group_by_field_name in self.FIELD_MAPPER:
|
||||
sub_group_ids = list(result_group_mapping[str(result_id)])
|
||||
# for multi groups
|
||||
result[self.FIELD_MAPPER.get(self.group_by_field_name)] = (
|
||||
[] if "None" in sub_group_ids else sub_group_ids
|
||||
sub_group_ids = list(
|
||||
result_sub_group_mapping[str(result_id)]
|
||||
)
|
||||
|
||||
# for multi groups
|
||||
result[
|
||||
self.FIELD_MAPPER.get(self.sub_group_by_field_name)
|
||||
] = ([] if "None" in sub_group_ids else sub_group_ids)
|
||||
# If a result belongs to multiple groups, add it to each group
|
||||
processed_results[str(group_value)]["results"][
|
||||
str(sub_group_value)
|
||||
]["results"].append(result)
|
||||
@@ -677,8 +710,10 @@ class SubGroupedOffsetPaginator(OffsetPaginator):
|
||||
self.group_by_field_name in self.FIELD_MAPPER
|
||||
or self.sub_group_by_field_name in self.FIELD_MAPPER
|
||||
):
|
||||
# if the grouping is done through m2m then
|
||||
processed_results = self.__query_multi_grouper(results=results)
|
||||
else:
|
||||
# group it directly
|
||||
processed_results = self.__query_grouper(results=results)
|
||||
else:
|
||||
processed_results = {}
|
||||
|
||||
@@ -147,7 +147,7 @@ services:
|
||||
|
||||
plane-redis:
|
||||
<<: *app-env
|
||||
image: redis:7.2.4-alpine
|
||||
image: valkey/valkey:7.2.5-alpine
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
|
||||
@@ -9,7 +9,7 @@ volumes:
|
||||
|
||||
services:
|
||||
plane-redis:
|
||||
image: redis:7.2.4-alpine
|
||||
image: valkey/valkey:7.2.5-alpine
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- dev_env
|
||||
|
||||
+1
-1
@@ -116,7 +116,7 @@ services:
|
||||
|
||||
plane-redis:
|
||||
container_name: plane-redis
|
||||
image: redis:7.2.4-alpine
|
||||
image: valkey/valkey:7.2.5-alpine
|
||||
restart: always
|
||||
volumes:
|
||||
- redisdata:/data
|
||||
|
||||
+3
-2
@@ -34,10 +34,11 @@
|
||||
"prettier": "latest",
|
||||
"prettier-plugin-tailwindcss": "^0.5.4",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"turbo": "^1.13.2"
|
||||
"turbo": "^2.0.4"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "18.2.48"
|
||||
},
|
||||
"packageManager": "yarn@1.22.19"
|
||||
"packageManager": "yarn@1.22.22",
|
||||
"name": "plane"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
export enum EAuthPageTypes {
|
||||
PUBLIC = "PUBLIC",
|
||||
NON_AUTHENTICATED = "NON_AUTHENTICATED",
|
||||
SET_PASSWORD = "SET_PASSWORD",
|
||||
ONBOARDING = "ONBOARDING",
|
||||
AUTHENTICATED = "AUTHENTICATED",
|
||||
}
|
||||
|
||||
export enum EAuthModes {
|
||||
SIGN_IN = "SIGN_IN",
|
||||
SIGN_UP = "SIGN_UP",
|
||||
}
|
||||
|
||||
export enum EAuthSteps {
|
||||
EMAIL = "EMAIL",
|
||||
PASSWORD = "PASSWORD",
|
||||
UNIQUE_CODE = "UNIQUE_CODE",
|
||||
}
|
||||
|
||||
// TODO: remove this
|
||||
export enum EErrorAlertType {
|
||||
BANNER_ALERT = "BANNER_ALERT",
|
||||
INLINE_FIRST_NAME = "INLINE_FIRST_NAME",
|
||||
INLINE_EMAIL = "INLINE_EMAIL",
|
||||
INLINE_PASSWORD = "INLINE_PASSWORD",
|
||||
INLINE_EMAIL_CODE = "INLINE_EMAIL_CODE",
|
||||
}
|
||||
|
||||
export enum EAuthErrorCodes {
|
||||
// Global
|
||||
INSTANCE_NOT_CONFIGURED = "5000",
|
||||
INVALID_EMAIL = "5005",
|
||||
EMAIL_REQUIRED = "5010",
|
||||
SIGNUP_DISABLED = "5015",
|
||||
MAGIC_LINK_LOGIN_DISABLED = "5016",
|
||||
PASSWORD_LOGIN_DISABLED = "5018",
|
||||
USER_ACCOUNT_DEACTIVATED = "5019",
|
||||
// Password strength
|
||||
INVALID_PASSWORD = "5020",
|
||||
SMTP_NOT_CONFIGURED = "5025",
|
||||
// Sign Up
|
||||
USER_ALREADY_EXIST = "5030",
|
||||
AUTHENTICATION_FAILED_SIGN_UP = "5035",
|
||||
REQUIRED_EMAIL_PASSWORD_SIGN_UP = "5040",
|
||||
INVALID_EMAIL_SIGN_UP = "5045",
|
||||
INVALID_EMAIL_MAGIC_SIGN_UP = "5050",
|
||||
MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED = "5055",
|
||||
// Sign In
|
||||
USER_DOES_NOT_EXIST = "5060",
|
||||
AUTHENTICATION_FAILED_SIGN_IN = "5065",
|
||||
REQUIRED_EMAIL_PASSWORD_SIGN_IN = "5070",
|
||||
INVALID_EMAIL_SIGN_IN = "5075",
|
||||
INVALID_EMAIL_MAGIC_SIGN_IN = "5080",
|
||||
MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED = "5085",
|
||||
// Both Sign in and Sign up for magic
|
||||
INVALID_MAGIC_CODE_SIGN_IN = "5090",
|
||||
INVALID_MAGIC_CODE_SIGN_UP = "5092",
|
||||
EXPIRED_MAGIC_CODE_SIGN_IN = "5095",
|
||||
EXPIRED_MAGIC_CODE_SIGN_UP = "5097",
|
||||
EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN = "5100",
|
||||
EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP = "5102",
|
||||
// Oauth
|
||||
OAUTH_NOT_CONFIGURED = "5104",
|
||||
GOOGLE_NOT_CONFIGURED = "5105",
|
||||
GITHUB_NOT_CONFIGURED = "5110",
|
||||
GITLAB_NOT_CONFIGURED = "5111",
|
||||
GOOGLE_OAUTH_PROVIDER_ERROR = "5115",
|
||||
GITHUB_OAUTH_PROVIDER_ERROR = "5120",
|
||||
GITLAB_OAUTH_PROVIDER_ERROR = "5121",
|
||||
// Reset Password
|
||||
INVALID_PASSWORD_TOKEN = "5125",
|
||||
EXPIRED_PASSWORD_TOKEN = "5130",
|
||||
// Change password
|
||||
INCORRECT_OLD_PASSWORD = "5135",
|
||||
MISSING_PASSWORD = "5138",
|
||||
INVALID_NEW_PASSWORD = "5140",
|
||||
// set passowrd
|
||||
PASSWORD_ALREADY_SET = "5145",
|
||||
// Admin
|
||||
ADMIN_ALREADY_EXIST = "5150",
|
||||
REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME = "5155",
|
||||
INVALID_ADMIN_EMAIL = "5160",
|
||||
INVALID_ADMIN_PASSWORD = "5165",
|
||||
REQUIRED_ADMIN_EMAIL_PASSWORD = "5170",
|
||||
ADMIN_AUTHENTICATION_FAILED = "5175",
|
||||
ADMIN_USER_ALREADY_EXIST = "5180",
|
||||
ADMIN_USER_DOES_NOT_EXIST = "5185",
|
||||
ADMIN_USER_DEACTIVATED = "5190",
|
||||
// Rate limit
|
||||
RATE_LIMIT_EXCEEDED = "5900",
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "@plane/constants",
|
||||
"version": "0.21.0",
|
||||
"private": true,
|
||||
"main": "./index.ts"
|
||||
}
|
||||
@@ -59,6 +59,9 @@ export const useDocumentEditor = ({
|
||||
// indexedDB provider
|
||||
useLayoutEffect(() => {
|
||||
const localProvider = new IndexeddbPersistence(id, provider.document);
|
||||
localProvider.on("synced", () => {
|
||||
provider.setHasIndexedDBSynced(true);
|
||||
});
|
||||
return () => {
|
||||
localProvider?.destroy();
|
||||
};
|
||||
|
||||
@@ -13,6 +13,10 @@ export interface CompleteCollaboratorProviderConfiguration {
|
||||
* onChange callback
|
||||
*/
|
||||
onChange: (updates: Uint8Array) => void;
|
||||
/**
|
||||
* Whether connection to the database has been established and all available content has been loaded or not.
|
||||
*/
|
||||
hasIndexedDBSynced: boolean;
|
||||
}
|
||||
|
||||
export type CollaborationProviderConfiguration = Required<Pick<CompleteCollaboratorProviderConfiguration, "name">> &
|
||||
@@ -24,6 +28,7 @@ export class CollaborationProvider {
|
||||
// @ts-expect-error cannot be undefined
|
||||
document: undefined,
|
||||
onChange: () => {},
|
||||
hasIndexedDBSynced: false,
|
||||
};
|
||||
|
||||
constructor(configuration: CollaborationProviderConfiguration) {
|
||||
@@ -45,7 +50,12 @@ export class CollaborationProvider {
|
||||
return this.configuration.document;
|
||||
}
|
||||
|
||||
setHasIndexedDBSynced(hasIndexedDBSynced: boolean) {
|
||||
this.configuration.hasIndexedDBSynced = hasIndexedDBSynced;
|
||||
}
|
||||
|
||||
documentUpdateHandler(update: Uint8Array, origin: any) {
|
||||
if (!this.configuration.hasIndexedDBSynced) return;
|
||||
// return if the update is from the provider itself
|
||||
if (origin === this) return;
|
||||
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
module.exports = {
|
||||
extends: [
|
||||
"next",
|
||||
"turbo",
|
||||
"prettier",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
],
|
||||
extends: ["next", "prettier", "plugin:@typescript-eslint/recommended"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
ecmaVersion: 2021, // Or the ECMAScript version you are using
|
||||
@@ -16,11 +11,20 @@ module.exports = {
|
||||
rootDir: ["web/", "space/", "admin/", "packages/*/"],
|
||||
},
|
||||
},
|
||||
globals: {
|
||||
React: "readonly",
|
||||
JSX: "readonly",
|
||||
},
|
||||
rules: {
|
||||
"no-useless-escape": "off",
|
||||
"prefer-const": "error",
|
||||
"no-irregular-whitespace": "error",
|
||||
"no-trailing-spaces": "error",
|
||||
"no-duplicate-imports": "error",
|
||||
"no-useless-catch": "warn",
|
||||
"no-case-declarations": "error",
|
||||
"no-undef": "error",
|
||||
"no-unreachable": "error",
|
||||
"arrow-body-style": ["error", "as-needed"],
|
||||
"@next/next/no-html-link-for-pages": "off",
|
||||
"@next/next/no-img-element": "off",
|
||||
@@ -28,6 +32,7 @@ module.exports = {
|
||||
"react/self-closing-comp": ["error", { component: true, html: true }],
|
||||
"react/jsx-boolean-value": "error",
|
||||
"react/jsx-no-duplicate-props": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"@typescript-eslint/no-unused-vars": ["error"],
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-useless-empty-export": "error",
|
||||
@@ -37,6 +42,7 @@ module.exports = {
|
||||
{
|
||||
selector: ["function", "variable"],
|
||||
format: ["camelCase", "snake_case", "UPPER_CASE", "PascalCase"],
|
||||
leadingUnderscore: "allow",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ export type TCurrentUserAccount = {
|
||||
user: string | undefined;
|
||||
|
||||
provider_account_id: string | undefined;
|
||||
provider: "google" | "github" | string | undefined;
|
||||
provider: "google" | "github" | "gitlab" | string | undefined;
|
||||
access_token: string | undefined;
|
||||
access_token_expired_at: Date | undefined;
|
||||
refresh_token: string | undefined;
|
||||
|
||||
Vendored
+10
@@ -75,3 +75,13 @@ export type TEstimateUpdateStageKeys =
|
||||
| EEstimateUpdateStages.CREATE
|
||||
| EEstimateUpdateStages.EDIT
|
||||
| EEstimateUpdateStages.SWITCH;
|
||||
|
||||
export type TEstimateTypeErrorObject = {
|
||||
oldValue: string;
|
||||
newValue: string;
|
||||
message: string | undefined;
|
||||
};
|
||||
|
||||
export type TEstimateTypeError =
|
||||
| Record<number, TEstimateTypeErrorObject>
|
||||
| undefined;
|
||||
|
||||
+9
-2
@@ -3,7 +3,8 @@ export type TInstanceAuthenticationMethodKeys =
|
||||
| "ENABLE_MAGIC_LINK_LOGIN"
|
||||
| "ENABLE_EMAIL_PASSWORD"
|
||||
| "IS_GOOGLE_ENABLED"
|
||||
| "IS_GITHUB_ENABLED";
|
||||
| "IS_GITHUB_ENABLED"
|
||||
| "IS_GITLAB_ENABLED";
|
||||
|
||||
export type TInstanceGoogleAuthenticationConfigurationKeys =
|
||||
| "GOOGLE_CLIENT_ID"
|
||||
@@ -13,9 +14,15 @@ export type TInstanceGithubAuthenticationConfigurationKeys =
|
||||
| "GITHUB_CLIENT_ID"
|
||||
| "GITHUB_CLIENT_SECRET";
|
||||
|
||||
export type TInstanceGitlabAuthenticationConfigurationKeys =
|
||||
| "GITLAB_HOST"
|
||||
| "GITLAB_CLIENT_ID"
|
||||
| "GITLAB_CLIENT_SECRET";
|
||||
|
||||
type TInstanceAuthenticationConfigurationKeys =
|
||||
| TInstanceGoogleAuthenticationConfigurationKeys
|
||||
| TInstanceGithubAuthenticationConfigurationKeys;
|
||||
| TInstanceGithubAuthenticationConfigurationKeys
|
||||
| TInstanceGitlabAuthenticationConfigurationKeys;
|
||||
|
||||
export type TInstanceAuthenticationKeys =
|
||||
| TInstanceAuthenticationMethodKeys
|
||||
|
||||
+1
@@ -38,6 +38,7 @@ export interface IInstance {
|
||||
export interface IInstanceConfig {
|
||||
is_google_enabled: boolean;
|
||||
is_github_enabled: boolean;
|
||||
is_gitlab_enabled: boolean;
|
||||
is_magic_login_enabled: boolean;
|
||||
is_email_password_enabled: boolean;
|
||||
github_app_name: string | undefined;
|
||||
|
||||
Vendored
+3
-2
@@ -3,6 +3,7 @@ import { EPageAccess } from "./enums";
|
||||
|
||||
export type TPage = {
|
||||
access: EPageAccess | undefined;
|
||||
anchor?: string | null | undefined;
|
||||
archived_at: string | null | undefined;
|
||||
color: string | undefined;
|
||||
created_at: Date | undefined;
|
||||
@@ -11,10 +12,10 @@ export type TPage = {
|
||||
id: string | undefined;
|
||||
is_favorite: boolean;
|
||||
is_locked: boolean;
|
||||
labels: string[] | undefined;
|
||||
label_ids: string[] | undefined;
|
||||
name: string | undefined;
|
||||
owned_by: string | undefined;
|
||||
project: string | undefined;
|
||||
project_ids: string[] | undefined;
|
||||
updated_at: Date | undefined;
|
||||
updated_by: string | undefined;
|
||||
workspace: string | undefined;
|
||||
|
||||
Vendored
+10
-7
@@ -1,6 +1,6 @@
|
||||
import { IProject, IProjectLite, IWorkspaceLite } from "@plane/types";
|
||||
|
||||
export type TPublishEntityType = "project";
|
||||
export type TPublishEntityType = "project" | "page";
|
||||
|
||||
export type TProjectPublishLayouts =
|
||||
| "calendar"
|
||||
@@ -9,7 +9,7 @@ export type TProjectPublishLayouts =
|
||||
| "list"
|
||||
| "spreadsheet";
|
||||
|
||||
export type TPublishViewProps = {
|
||||
export type TProjectPublishViewProps = {
|
||||
calendar?: boolean;
|
||||
gantt?: boolean;
|
||||
kanban?: boolean;
|
||||
@@ -20,22 +20,25 @@ export type TPublishViewProps = {
|
||||
export type TProjectDetails = IProjectLite &
|
||||
Pick<IProject, "cover_image" | "logo_props" | "description">;
|
||||
|
||||
export type TPublishSettings = {
|
||||
type TPublishSettings = {
|
||||
anchor: string | undefined;
|
||||
is_comments_enabled: boolean;
|
||||
created_at: string | undefined;
|
||||
created_by: string | undefined;
|
||||
entity_identifier: string | undefined;
|
||||
entity_name: TPublishEntityType | undefined;
|
||||
id: string | undefined;
|
||||
inbox: unknown;
|
||||
is_comments_enabled: boolean;
|
||||
is_reactions_enabled: boolean;
|
||||
is_votes_enabled: boolean;
|
||||
project: string | undefined;
|
||||
project_details: TProjectDetails | undefined;
|
||||
is_reactions_enabled: boolean;
|
||||
updated_at: string | undefined;
|
||||
updated_by: string | undefined;
|
||||
view_props: TViewProps | undefined;
|
||||
is_votes_enabled: boolean;
|
||||
workspace: string | undefined;
|
||||
workspace_detail: IWorkspaceLite | undefined;
|
||||
};
|
||||
|
||||
export type TProjectPublishSettings = TPublishSettings & {
|
||||
view_props: TProjectPublishViewProps | undefined;
|
||||
};
|
||||
|
||||
Vendored
+1
-1
@@ -5,7 +5,7 @@ import {
|
||||
TStateGroups,
|
||||
} from ".";
|
||||
|
||||
type TLoginMediums = "email" | "magic-code" | "github" | "google";
|
||||
type TLoginMediums = "email" | "magic-code" | "github" | "gitlab" | "google";
|
||||
|
||||
export interface IUser {
|
||||
id: string;
|
||||
|
||||
Vendored
+3
@@ -13,6 +13,9 @@ export interface IProjectView {
|
||||
is_favorite: boolean;
|
||||
created_by: string;
|
||||
updated_by: string;
|
||||
isLocked: boolean | null;
|
||||
isPrivate: boolean | null;
|
||||
isPublished: boolean | null;
|
||||
name: string;
|
||||
description: string;
|
||||
filters: IIssueFilterOptions;
|
||||
|
||||
Vendored
+9
-1
@@ -112,6 +112,14 @@ export interface IWorkspaceIssueSearchResult {
|
||||
workspace__slug: string;
|
||||
}
|
||||
|
||||
export interface IWorkspacePageSearchResult {
|
||||
id: string;
|
||||
name: string;
|
||||
project_ids: string[];
|
||||
project__identifiers: string[];
|
||||
workspace__slug: string;
|
||||
}
|
||||
|
||||
export interface IWorkspaceProjectSearchResult {
|
||||
id: string;
|
||||
identifier: string;
|
||||
@@ -127,7 +135,7 @@ export interface IWorkspaceSearchResults {
|
||||
cycle: IWorkspaceDefaultSearchResult[];
|
||||
module: IWorkspaceDefaultSearchResult[];
|
||||
issue_view: IWorkspaceDefaultSearchResult[];
|
||||
page: IWorkspaceDefaultSearchResult[];
|
||||
page: IWorkspacePageSearchResult[];
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-popper": "^2.3.0",
|
||||
"sonner": "^1.4.41",
|
||||
"tailwind-merge": "^2.0.0"
|
||||
"tailwind-merge": "^2.0.0",
|
||||
"use-font-face-observer": "^1.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^1.4.0",
|
||||
|
||||
@@ -31,7 +31,7 @@ export const DropdownButton: React.FC<IMultiSelectDropdownButton | ISingleSelect
|
||||
)}
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
{buttonContent ? <>{buttonContent(isOpen)}</> : <span className={cn("", buttonClassName)}>{value}</span>}
|
||||
{buttonContent ? <>{buttonContent(isOpen, value)}</> : <span className={cn("", buttonClassName)}>{value}</span>}
|
||||
</button>
|
||||
</Combobox.Button>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ export const DropdownOptions: React.FC<IMultiSelectDropdownOptions | ISingleSele
|
||||
disableSearch,
|
||||
keyExtractor,
|
||||
options,
|
||||
handleClose,
|
||||
value,
|
||||
renderItem,
|
||||
loader,
|
||||
@@ -46,7 +47,7 @@ export const DropdownOptions: React.FC<IMultiSelectDropdownOptions | ISingleSele
|
||||
options?.map((option) => (
|
||||
<Combobox.Option
|
||||
key={keyExtractor(option)}
|
||||
value={option.data[option.value]}
|
||||
value={option.value}
|
||||
className={({ active, selected }) =>
|
||||
cn(
|
||||
"flex w-full cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5",
|
||||
@@ -58,6 +59,7 @@ export const DropdownOptions: React.FC<IMultiSelectDropdownOptions | ISingleSele
|
||||
option.className && option.className({ active, selected })
|
||||
)
|
||||
}
|
||||
onClick={handleClose}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
@@ -65,7 +67,7 @@ export const DropdownOptions: React.FC<IMultiSelectDropdownOptions | ISingleSele
|
||||
<>{renderItem({ value: option.data[option.value], selected })}</>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-grow truncate">{value}</span>
|
||||
<span className="flex-grow truncate">{option.value}</span>
|
||||
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||
</>
|
||||
)}
|
||||
|
||||
+5
-3
@@ -24,8 +24,8 @@ export interface IDropdown {
|
||||
// options props
|
||||
keyExtractor: (option: TDropdownOption) => string;
|
||||
optionsContainerClassName?: string;
|
||||
queryArray: string[];
|
||||
sortByKey: string;
|
||||
queryArray?: string[];
|
||||
sortByKey?: string;
|
||||
firstItem?: (optionValue: string) => boolean;
|
||||
renderItem?: ({ value, selected }: { value: string; selected: boolean }) => React.ReactNode;
|
||||
loader?: React.ReactNode;
|
||||
@@ -52,7 +52,7 @@ export interface ISingleSelectDropdown extends IDropdown {
|
||||
|
||||
export interface IDropdownButton {
|
||||
isOpen: boolean;
|
||||
buttonContent?: (isOpen: boolean) => React.ReactNode;
|
||||
buttonContent?: (isOpen: boolean, value: string | string[] | undefined) => React.ReactNode;
|
||||
buttonClassName?: string;
|
||||
buttonContainerClassName?: string;
|
||||
handleOnClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
@@ -79,6 +79,8 @@ export interface IDropdownOptions {
|
||||
inputContainerClassName?: string;
|
||||
disableSearch?: boolean;
|
||||
|
||||
handleClose?: () => void;
|
||||
|
||||
keyExtractor: (option: TDropdownOption) => string;
|
||||
renderItem: (({ value, selected }: { value: string; selected: boolean }) => React.ReactNode) | undefined;
|
||||
options: TDropdownOption[] | undefined;
|
||||
|
||||
@@ -90,10 +90,12 @@ export const MultiSelectDropdown: FC<IMultiSelectDropdown> = (props) => {
|
||||
const sortedOptions = useMemo(() => {
|
||||
if (!options) return undefined;
|
||||
|
||||
const filteredOptions = (options || []).filter((options) => {
|
||||
const queryString = queryArray.map((query) => options.data[query]).join(" ");
|
||||
return queryString.toLowerCase().includes(query.toLowerCase());
|
||||
});
|
||||
const filteredOptions = queryArray
|
||||
? (options || []).filter((options) => {
|
||||
const queryString = queryArray.map((query) => options.data[query]).join(" ");
|
||||
return queryString.toLowerCase().includes(query.toLowerCase());
|
||||
})
|
||||
: options;
|
||||
|
||||
if (disableSorting) return filteredOptions;
|
||||
|
||||
|
||||
@@ -90,12 +90,14 @@ export const Dropdown: FC<ISingleSelectDropdown> = (props) => {
|
||||
const sortedOptions = useMemo(() => {
|
||||
if (!options) return undefined;
|
||||
|
||||
const filteredOptions = (options || []).filter((options) => {
|
||||
const queryString = queryArray.map((query) => options.data[query]).join(" ");
|
||||
return queryString.toLowerCase().includes(query.toLowerCase());
|
||||
});
|
||||
const filteredOptions = queryArray
|
||||
? (options || []).filter((options) => {
|
||||
const queryString = queryArray.map((query) => options.data[query]).join(" ");
|
||||
return queryString.toLowerCase().includes(query.toLowerCase());
|
||||
})
|
||||
: options;
|
||||
|
||||
if (disableSorting) return filteredOptions;
|
||||
if (disableSorting || !sortByKey) return filteredOptions;
|
||||
|
||||
return sortBy(filteredOptions, [
|
||||
(option) => firstItem && firstItem(option.data[option.value]),
|
||||
@@ -157,6 +159,7 @@ export const Dropdown: FC<ISingleSelectDropdown> = (props) => {
|
||||
value={value}
|
||||
renderItem={renderItem}
|
||||
loader={loader}
|
||||
handleClose={handleClose}
|
||||
/>
|
||||
</div>
|
||||
</Combobox.Options>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export const emojiCodeToUnicode = (emoji: string) => {
|
||||
if (!emoji) return "";
|
||||
|
||||
// convert emoji code to unicode
|
||||
const uniCodeEmoji = emoji
|
||||
.split("-")
|
||||
.map((emoji) => parseInt(emoji, 10).toString(16))
|
||||
.join("-");
|
||||
|
||||
return uniCodeEmoji;
|
||||
};
|
||||
@@ -1,13 +1,15 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
// icons
|
||||
import { Search } from "lucide-react";
|
||||
import { MATERIAL_ICONS_LIST } from "./icons";
|
||||
import { InfoIcon } from "../icons";
|
||||
// components
|
||||
import { Input } from "../form-fields";
|
||||
// hooks
|
||||
import useFontFaceObserver from "use-font-face-observer";
|
||||
// helpers
|
||||
import { cn } from "../../helpers";
|
||||
import { DEFAULT_COLORS, TIconsListProps, adjustColorForContrast } from "./emoji-icon-helper";
|
||||
// icons
|
||||
import { MATERIAL_ICONS_LIST } from "./icons";
|
||||
import { InfoIcon } from "../icons";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
export const IconsList: React.FC<TIconsListProps> = (props) => {
|
||||
const { defaultColor, onChange } = props;
|
||||
@@ -28,6 +30,15 @@ export const IconsList: React.FC<TIconsListProps> = (props) => {
|
||||
|
||||
const filteredArray = MATERIAL_ICONS_LIST.filter((icon) => icon.name.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
const isMaterialSymbolsFontLoaded = useFontFaceObserver([
|
||||
{
|
||||
family: `Material Symbols Rounded`,
|
||||
style: `normal`,
|
||||
weight: `normal`,
|
||||
stretch: `condensed`,
|
||||
},
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center px-2 py-[15px] w-full ">
|
||||
@@ -118,12 +129,16 @@ export const IconsList: React.FC<TIconsListProps> = (props) => {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{ color: activeColor }}
|
||||
className="material-symbols-rounded !text-[1.25rem] !leading-[1.25rem]"
|
||||
>
|
||||
{icon.name}
|
||||
</span>
|
||||
{isMaterialSymbolsFontLoaded ? (
|
||||
<span
|
||||
style={{ color: activeColor }}
|
||||
className="material-symbols-rounded !text-[1.25rem] !leading-[1.25rem]"
|
||||
>
|
||||
{icon.name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="size-5 rounded animate-pulse bg-custom-background-80" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from "./emoji-icon-picker-new";
|
||||
export * from "./emoji-icon-picker";
|
||||
export * from "./emoji-icon-helper";
|
||||
export * from "./icons";
|
||||
export * from "./logo";
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import React, { FC } from "react";
|
||||
import { Emoji } from "emoji-picker-react";
|
||||
import useFontFaceObserver from "use-font-face-observer";
|
||||
// icons
|
||||
import { LUCIDE_ICONS_LIST } from "./icons";
|
||||
// helpers
|
||||
import { emojiCodeToUnicode } from "./helpers";
|
||||
|
||||
type TLogoProps = {
|
||||
in_use: "emoji" | "icon";
|
||||
emoji?: {
|
||||
value?: string;
|
||||
url?: string;
|
||||
};
|
||||
icon?: {
|
||||
name?: string;
|
||||
color?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type Props = {
|
||||
logo: TLogoProps;
|
||||
size?: number;
|
||||
type?: "lucide" | "material";
|
||||
};
|
||||
|
||||
export const Logo: FC<Props> = (props) => {
|
||||
const { logo, size = 16, type = "material" } = props;
|
||||
|
||||
// destructuring the logo object
|
||||
const { in_use, emoji, icon } = logo;
|
||||
|
||||
// derived values
|
||||
const value = in_use === "emoji" ? emoji?.value : icon?.name;
|
||||
const color = icon?.color;
|
||||
const lucideIcon = LUCIDE_ICONS_LIST.find((item) => item.name === value);
|
||||
|
||||
const isMaterialSymbolsFontLoaded = useFontFaceObserver([
|
||||
{
|
||||
family: `Material Symbols Rounded`,
|
||||
style: `normal`,
|
||||
weight: `normal`,
|
||||
stretch: `condensed`,
|
||||
},
|
||||
]);
|
||||
// if no value, return empty fragment
|
||||
if (!value) return <></>;
|
||||
|
||||
if (!isMaterialSymbolsFontLoaded) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
height: size,
|
||||
width: size,
|
||||
}}
|
||||
className="rounded animate-pulse bg-custom-background-80"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// emoji
|
||||
if (in_use === "emoji") {
|
||||
return <Emoji unified={emojiCodeToUnicode(value)} size={size} />;
|
||||
}
|
||||
|
||||
// icon
|
||||
if (in_use === "icon") {
|
||||
return (
|
||||
<>
|
||||
{type === "lucide" ? (
|
||||
<>
|
||||
{lucideIcon && (
|
||||
<lucideIcon.element
|
||||
style={{
|
||||
color: color,
|
||||
height: size,
|
||||
width: size,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span
|
||||
className="material-symbols-rounded"
|
||||
style={{
|
||||
fontSize: size,
|
||||
color: color,
|
||||
scale: "115%",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// if no value, return empty fragment
|
||||
return <></>;
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { ISvgIcons } from "./type";
|
||||
|
||||
export const GitlabIcon: React.FC<ISvgIcons> = ({ width = "24", height = "24", className, color }) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clipPath="url(#clip0_282_232)">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M10 0C4.475 0 0 4.475 0 10C0 14.425 2.8625 18.1625 6.8375 19.4875C7.3375 19.575 7.525 19.275 7.525 19.0125C7.525 18.775 7.5125 17.9875 7.5125 17.15C5 17.6125 4.35 16.5375 4.15 15.975C4.0375 15.6875 3.55 14.8 3.125 14.5625C2.775 14.375 2.275 13.9125 3.1125 13.9C3.9 13.8875 4.4625 14.625 4.65 14.925C5.55 16.4375 6.9875 16.0125 7.5625 15.75C7.65 15.1 7.9125 14.6625 8.2 14.4125C5.975 14.1625 3.65 13.3 3.65 9.475C3.65 8.3875 4.0375 7.4875 4.675 6.7875C4.575 6.5375 4.225 5.5125 4.775 4.1375C4.775 4.1375 5.6125 3.875 7.525 5.1625C8.325 4.9375 9.175 4.825 10.025 4.825C10.875 4.825 11.725 4.9375 12.525 5.1625C14.4375 3.8625 15.275 4.1375 15.275 4.1375C15.825 5.5125 15.475 6.5375 15.375 6.7875C16.0125 7.4875 16.4 8.375 16.4 9.475C16.4 13.3125 14.0625 14.1625 11.8375 14.4125C12.2 14.725 12.5125 15.325 12.5125 16.2625C12.5125 17.6 12.5 18.675 12.5 19.0125C12.5 19.275 12.6875 19.5875 13.1875 19.4875C15.1726 18.8173 16.8976 17.5414 18.1197 15.8395C19.3418 14.1375 19.9994 12.0952 20 10C20 4.475 15.525 0 10 0Z"
|
||||
fill={color ? color : "rgb(var(--color-text-200))"}
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_282_232">
|
||||
<rect width={width} height={height} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
@@ -12,6 +12,7 @@ export * from "./dice-icon";
|
||||
export * from "./discord-icon";
|
||||
export * from "./full-screen-panel-icon";
|
||||
export * from "./github-icon";
|
||||
export * from "./gitlab-icon";
|
||||
export * from "./layer-stack";
|
||||
export * from "./layers-icon";
|
||||
export * from "./photo-filter-icon";
|
||||
|
||||
@@ -7,6 +7,7 @@ export * from "./dropdowns";
|
||||
export * from "./dropdown";
|
||||
export * from "./form-fields";
|
||||
export * from "./icons";
|
||||
export * from "./modals";
|
||||
export * from "./progress";
|
||||
export * from "./spinners";
|
||||
export * from "./tooltip";
|
||||
|
||||
+6
-5
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { AlertTriangle, Info, LucideIcon } from "lucide-react";
|
||||
// ui
|
||||
import { Button, TButtonVariant } from "@plane/ui";
|
||||
// components
|
||||
import { EModalPosition, EModalWidth, ModalCore } from "@/components/core";
|
||||
import { Button, TButtonVariant } from "../button";
|
||||
import { ModalCore } from "./modal-core";
|
||||
// constants
|
||||
import { EModalPosition, EModalWidth } from "./constants";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { cn } from "../../helpers";
|
||||
|
||||
export type TModalVariant = "danger" | "primary";
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export enum EModalPosition {
|
||||
TOP = "flex items-center justify-center text-center mx-4 my-10 md:my-20",
|
||||
CENTER = "flex items-end sm:items-center justify-center p-4 min-h-full",
|
||||
}
|
||||
|
||||
export enum EModalWidth {
|
||||
XL = "sm:max-w-xl",
|
||||
XXL = "sm:max-w-2xl",
|
||||
XXXL = "sm:max-w-3xl",
|
||||
XXXXL = "sm:max-w-4xl",
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./alert-modal";
|
||||
export * from "./constants";
|
||||
export * from "./modal-core";
|
||||
+4
-14
@@ -1,19 +1,9 @@
|
||||
import { Fragment } from "react";
|
||||
import React, { Fragment } from "react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// constants
|
||||
import { EModalPosition, EModalWidth } from "./constants";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
|
||||
export enum EModalPosition {
|
||||
TOP = "flex items-center justify-center text-center mx-4 my-10 md:my-20",
|
||||
CENTER = "flex items-end sm:items-center justify-center p-4 min-h-full",
|
||||
}
|
||||
|
||||
export enum EModalWidth {
|
||||
XL = "sm:max-w-xl",
|
||||
XXL = "sm:max-w-2xl",
|
||||
XXXL = "sm:max-w-3xl",
|
||||
XXXXL = "sm:max-w-4xl",
|
||||
}
|
||||
import { cn } from "../../helpers";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
// types
|
||||
import { TPublishSettings } from "@plane/types";
|
||||
import { TProjectPublishSettings } from "@plane/types";
|
||||
// services
|
||||
import PublishService from "@/services/publish.service";
|
||||
|
||||
@@ -20,7 +20,7 @@ export default async function IssuesPage(props: Props) {
|
||||
const { workspaceSlug, projectId } = params;
|
||||
const { board, peekId } = searchParams;
|
||||
|
||||
let response: TPublishSettings | undefined = undefined;
|
||||
let response: TProjectPublishSettings | undefined = undefined;
|
||||
try {
|
||||
response = await publishService.fetchAnchorFromProjectDetails(workspaceSlug, projectId);
|
||||
} catch (error) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import useSWR from "swr";
|
||||
import { LogoSpinner } from "@/components/common";
|
||||
import { IssuesNavbarRoot } from "@/components/issues";
|
||||
// hooks
|
||||
import { usePublish, usePublishList } from "@/hooks/store";
|
||||
import { useIssueFilter, usePublish, usePublishList } from "@/hooks/store";
|
||||
// assets
|
||||
import planeLogo from "@/public/plane-logo.svg";
|
||||
|
||||
@@ -25,8 +25,25 @@ const IssuesLayout = observer((props: Props) => {
|
||||
// store hooks
|
||||
const { fetchPublishSettings } = usePublishList();
|
||||
const publishSettings = usePublish(anchor);
|
||||
const { updateLayoutOptions } = useIssueFilter();
|
||||
// fetch publish settings
|
||||
useSWR(anchor ? `PUBLISH_SETTINGS_${anchor}` : null, anchor ? () => fetchPublishSettings(anchor) : null);
|
||||
useSWR(
|
||||
anchor ? `PUBLISH_SETTINGS_${anchor}` : null,
|
||||
anchor
|
||||
? async () => {
|
||||
const response = await fetchPublishSettings(anchor);
|
||||
if (response.view_props) {
|
||||
updateLayoutOptions({
|
||||
list: !!response.view_props.list,
|
||||
kanban: !!response.view_props.kanban,
|
||||
calendar: !!response.view_props.calendar,
|
||||
gantt: !!response.view_props.gantt,
|
||||
spreadsheet: !!response.view_props.spreadsheet,
|
||||
});
|
||||
}
|
||||
}
|
||||
: null
|
||||
);
|
||||
|
||||
if (!publishSettings) return <LogoSpinner />;
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// store
|
||||
import { CoreRootStore } from "@/store/root.store";
|
||||
|
||||
export class RootStore extends CoreRootStore {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -85,7 +85,7 @@ export const AuthRoot: FC = observer(() => {
|
||||
const isSMTPConfigured = config?.is_smtp_configured || false;
|
||||
const isMagicLoginEnabled = config?.is_magic_login_enabled || false;
|
||||
const isEmailPasswordEnabled = config?.is_email_password_enabled || false;
|
||||
const isOAuthEnabled = (config && (config?.is_google_enabled || config?.is_github_enabled)) || false;
|
||||
const isOAuthEnabled = (config && (config?.is_google_enabled || config?.is_github_enabled || config?.is_gitlab_enabled)) || false;
|
||||
|
||||
// submit handler- email verification
|
||||
const handleEmailVerification = async (data: IEmailCheckData) => {
|
||||
+4
-3
@@ -66,9 +66,10 @@ export const AuthEmailForm: FC<TAuthEmailForm> = observer((props) => {
|
||||
autoFocus
|
||||
/>
|
||||
{email.length > 0 && (
|
||||
<div className="flex-shrink-0 h-5 w-5 mr-2 bg-onboarding-background-200 hover:cursor-pointer">
|
||||
<XCircle className="h-5 w-5 stroke-custom-text-400" onClick={() => setEmail("")} />
|
||||
</div>
|
||||
<XCircle
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => setEmail("")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{emailError?.email && !isFocused && (
|
||||
+4
-3
@@ -117,9 +117,10 @@ export const AuthPasswordForm: React.FC<Props> = observer((props: Props) => {
|
||||
disabled
|
||||
/>
|
||||
{passwordFormData.email.length > 0 && (
|
||||
<div className="flex-shrink-0 h-5 w-5 mr-2 bg-onboarding-background-200 hover:cursor-pointer">
|
||||
<XCircle className="h-5 w-5 stroke-custom-text-400" onClick={handleEmailClear} />
|
||||
</div>
|
||||
<XCircle
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={handleEmailClear}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
+4
-3
@@ -101,9 +101,10 @@ export const AuthUniqueCodeForm: React.FC<TAuthUniqueCodeForm> = (props) => {
|
||||
disabled
|
||||
/>
|
||||
{uniqueCodeFormData.email.length > 0 && (
|
||||
<div className="flex-shrink-0 h-5 w-5 mr-2 bg-onboarding-background-200 hover:cursor-pointer">
|
||||
<XCircle className="h-5 w-5 stroke-custom-text-400" onClick={handleEmailClear} />
|
||||
</div>
|
||||
<XCircle
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={handleEmailClear}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { FC } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
// helpers
|
||||
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||
// images
|
||||
import GitlabLogo from "/public/logos/gitlab-logo.svg";
|
||||
|
||||
export type GitlabOAuthButtonProps = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
export const GitlabOAuthButton: FC<GitlabOAuthButtonProps> = (props) => {
|
||||
const searchParams = useSearchParams();
|
||||
const nextPath = searchParams.get("next_path") || undefined;
|
||||
const { text } = props;
|
||||
// hooks
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const handleSignIn = () => {
|
||||
window.location.assign(`${API_BASE_URL}/auth/spaces/gitlab/${nextPath ? `?next_path=${nextPath}` : ``}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`flex h-[42px] w-full items-center justify-center gap-2 rounded border px-2 text-sm font-medium text-custom-text-100 duration-300 hover:bg-onboarding-background-300 ${
|
||||
resolvedTheme === "dark" ? "border-[#43484F] bg-[#2F3135]" : "border-[#D9E4FF]"
|
||||
}`}
|
||||
onClick={handleSignIn}
|
||||
>
|
||||
<Image src={GitlabLogo} height={20} width={20} alt="GitLab Logo" />
|
||||
{text}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./oauth-options";
|
||||
export * from "./google-button";
|
||||
export * from "./github-button";
|
||||
export * from "./gitlab-button";
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
import { observer } from "mobx-react-lite";
|
||||
// components
|
||||
import { GithubOAuthButton, GoogleOAuthButton } from "@/components/account";
|
||||
import { GithubOAuthButton, GitlabOAuthButton, GoogleOAuthButton } from "@/components/account";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
@@ -22,6 +22,7 @@ export const OAuthOptions: React.FC = observer(() => {
|
||||
</div>
|
||||
)}
|
||||
{config?.is_github_enabled && <GithubOAuthButton text="Sign in with Github" />}
|
||||
{config?.is_gitlab_enabled && <GitlabOAuthButton text="Sign in with GitLab" />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user