Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99e1963d9b | ||
|
|
c24be25024 | ||
|
|
fb2b4ae303 | ||
|
|
de8da176d3 | ||
|
|
17ce1bceb6 | ||
|
|
1561b710ca | ||
|
|
9a4971efa4 | ||
|
|
2331404d46 | ||
|
|
a23c528396 | ||
|
|
dee57326a5 | ||
|
|
15918f2d9f | ||
|
|
8b6a48f05c | ||
|
|
1c849103f9 | ||
|
|
cdb932ab67 | ||
|
|
b1c7e6ae20 | ||
|
|
f5656111ee | ||
|
|
51758b774e | ||
|
|
d31aaee32c | ||
|
|
9af9268be6 | ||
|
|
c18a6a9654 | ||
|
|
282597bf83 | ||
|
|
b24e530816 | ||
|
|
ca9f3f2f5a | ||
|
|
028e70c4c1 | ||
|
|
30fdc1015c | ||
|
|
272428b05e | ||
|
|
911832d546 | ||
|
|
249e71e424 | ||
|
|
52d8d6e7ce | ||
|
|
93a22034bd | ||
|
|
453459d271 | ||
|
|
8c5f693214 | ||
|
|
4d17616670 | ||
|
|
c190bf3a6f | ||
|
|
e503c901ae | ||
|
|
188f8ff83f | ||
|
|
77b73dc032 | ||
|
|
20acb0dd17 | ||
|
|
7e66e2736b | ||
|
|
cad55f3234 | ||
|
|
87582604f7 | ||
|
|
dd65d03d33 | ||
|
|
97eea75cb7 | ||
|
|
ddfd953408 | ||
|
|
a428bc16c4 |
@@ -3,10 +3,11 @@ name: Build and Lint on Pull Request
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: ["opened", "synchronize"]
|
||||
types: ["opened", "synchronize", "ready_for_review"]
|
||||
|
||||
jobs:
|
||||
get-changed-files:
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
apiserver_changed: ${{ steps.changed-files.outputs.apiserver_any_changed }}
|
||||
|
||||
@@ -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/docker-compose).
|
||||
If you would like to self-host Plane, please see our [deployment guide](https://docs.plane.so/self-hosting/overview).
|
||||
|
||||
| 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;
|
||||
@@ -7,22 +7,24 @@ import { useTheme } from "next-themes";
|
||||
import useSWR from "swr";
|
||||
import { Mails, KeyRound } from "lucide-react";
|
||||
import { TInstanceConfigurationKeys } from "@plane/types";
|
||||
import { Loader, setPromiseToast } from "@plane/ui";
|
||||
import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui";
|
||||
// components
|
||||
import { PageHeader } from "@/components/core";
|
||||
// hooks
|
||||
// helpers
|
||||
import { resolveGeneralTheme } from "@/helpers/common.helper";
|
||||
import { cn, resolveGeneralTheme } from "@/helpers/common.helper";
|
||||
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";
|
||||
@@ -45,6 +47,8 @@ const InstanceAuthenticationPage = observer(() => {
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
// theme
|
||||
const { resolvedTheme } = useTheme();
|
||||
// derived values
|
||||
const enableSignUpConfig = formattedConfig?.ENABLE_SIGNUP ?? "";
|
||||
|
||||
const updateConfig = async (key: TInstanceConfigurationKeys, value: string) => {
|
||||
setIsSubmitting(true);
|
||||
@@ -114,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 (
|
||||
@@ -129,7 +140,34 @@ const InstanceAuthenticationPage = observer(() => {
|
||||
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
|
||||
{formattedConfig ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-lg font-medium">Authentication modes</div>
|
||||
<div className="text-lg font-medium pb-1">Sign-up configuration</div>
|
||||
<div className={cn("w-full flex items-center gap-14 rounded")}>
|
||||
<div className="flex grow items-center gap-4">
|
||||
<div className="grow">
|
||||
<div className={cn("font-medium leading-5 text-custom-text-100 text-sm")}>
|
||||
Allow anyone to sign up without invite
|
||||
</div>
|
||||
<div className={cn("font-normal leading-5 text-custom-text-300 text-xs")}>
|
||||
Toggling this off will disable self sign ups.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`shrink-0 pr-4 ${isSubmitting && "opacity-70"}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableSignUpConfig))}
|
||||
onChange={() => {
|
||||
Boolean(parseInt(enableSignUpConfig)) === true
|
||||
? updateConfig("ENABLE_SIGNUP", "0")
|
||||
: updateConfig("ENABLE_SIGNUP", "1");
|
||||
}}
|
||||
size="sm"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-lg font-medium pt-6">Authentication modes</div>
|
||||
{authenticationMethodsCard.map((method) => (
|
||||
<AuthenticationMethodCard
|
||||
key={method.key}
|
||||
|
||||
@@ -5,9 +5,11 @@ import { observer } from "mobx-react-lite";
|
||||
import Link from "next/link";
|
||||
import { ExternalLink, FileText, HelpCircle, MoveLeft } from "lucide-react";
|
||||
import { Transition } from "@headlessui/react";
|
||||
// ui
|
||||
import { DiscordIcon, GithubIcon, Tooltip } from "@plane/ui";
|
||||
// helpers
|
||||
import { WEB_BASE_URL, cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { WEB_BASE_URL } from "@/helpers/common.helper";
|
||||
import { useTheme } from "@/hooks/store";
|
||||
// assets
|
||||
import packageJson from "package.json";
|
||||
@@ -42,9 +44,12 @@ export const HelpSection: FC = observer(() => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex w-full items-center justify-between gap-1 self-baseline border-t border-custom-sidebar-border-200 bg-custom-sidebar-background-100 px-4 py-2 ${
|
||||
isSidebarCollapsed ? "flex-col" : ""
|
||||
}`}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-1 self-baseline border-t border-custom-border-200 bg-custom-sidebar-background-100 px-4 h-14 flex-shrink-0",
|
||||
{
|
||||
"flex-col h-auto py-1.5": isSidebarCollapsed,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className={`flex items-center gap-1 ${isSidebarCollapsed ? "flex-col justify-center" : "w-full"}`}>
|
||||
<Tooltip tooltipContent="Redirect to plane" position="right" className="ml-4" disabled={!isSidebarCollapsed}>
|
||||
|
||||
@@ -31,6 +31,8 @@ export const InstanceHeader: FC = observer(() => {
|
||||
return "Google";
|
||||
case "github":
|
||||
return "Github";
|
||||
case "gitlab":
|
||||
return "GitLab";
|
||||
default:
|
||||
return pathName.toUpperCase();
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"version": "0.20.0",
|
||||
"version": "0.21.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo run develop",
|
||||
|
||||
@@ -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
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"name": "plane-api",
|
||||
"version": "0.20.0"
|
||||
"version": "0.21.0"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import uuid
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.core.validators import validate_email
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
# Third party imports
|
||||
from zxcvbn import zxcvbn
|
||||
@@ -46,68 +48,71 @@ class Adapter:
|
||||
def authenticate(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def complete_login_or_signup(self):
|
||||
email = self.user_data.get("email")
|
||||
user = User.objects.filter(email=email).first()
|
||||
# Check if sign up case or login
|
||||
is_signup = bool(user)
|
||||
if not user:
|
||||
# New user
|
||||
(ENABLE_SIGNUP,) = get_configuration_value(
|
||||
[
|
||||
{
|
||||
"key": "ENABLE_SIGNUP",
|
||||
"default": os.environ.get("ENABLE_SIGNUP", "1"),
|
||||
},
|
||||
]
|
||||
)
|
||||
if (
|
||||
ENABLE_SIGNUP == "0"
|
||||
and not WorkspaceMemberInvite.objects.filter(
|
||||
email=email,
|
||||
).exists()
|
||||
):
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["SIGNUP_DISABLED"],
|
||||
error_message="SIGNUP_DISABLED",
|
||||
payload={"email": email},
|
||||
)
|
||||
user = User(email=email, username=uuid.uuid4().hex)
|
||||
|
||||
if self.user_data.get("user").get("is_password_autoset"):
|
||||
user.set_password(uuid.uuid4().hex)
|
||||
user.is_password_autoset = True
|
||||
user.is_email_verified = True
|
||||
else:
|
||||
# Validate password
|
||||
results = zxcvbn(self.code)
|
||||
if results["score"] < 3:
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES[
|
||||
"INVALID_PASSWORD"
|
||||
],
|
||||
error_message="INVALID_PASSWORD",
|
||||
payload={"email": email},
|
||||
)
|
||||
|
||||
user.set_password(self.code)
|
||||
user.is_password_autoset = False
|
||||
|
||||
avatar = self.user_data.get("user", {}).get("avatar", "")
|
||||
first_name = self.user_data.get("user", {}).get("first_name", "")
|
||||
last_name = self.user_data.get("user", {}).get("last_name", "")
|
||||
user.avatar = avatar if avatar else ""
|
||||
user.first_name = first_name if first_name else ""
|
||||
user.last_name = last_name if last_name else ""
|
||||
user.save()
|
||||
Profile.objects.create(user=user)
|
||||
|
||||
if not user.is_active:
|
||||
def sanitize_email(self, email):
|
||||
# Check if email is present
|
||||
if not email:
|
||||
raise AuthenticationException(
|
||||
AUTHENTICATION_ERROR_CODES["USER_ACCOUNT_DEACTIVATED"],
|
||||
error_message="USER_ACCOUNT_DEACTIVATED",
|
||||
error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
|
||||
error_message="INVALID_EMAIL",
|
||||
payload={"email": email},
|
||||
)
|
||||
|
||||
# Sanitize email
|
||||
email = str(email).lower().strip()
|
||||
|
||||
# validate email
|
||||
try:
|
||||
validate_email(email)
|
||||
except ValidationError:
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
|
||||
error_message="INVALID_EMAIL",
|
||||
payload={"email": email},
|
||||
)
|
||||
# Return email
|
||||
return email
|
||||
|
||||
def validate_password(self, email):
|
||||
"""Validate password strength"""
|
||||
results = zxcvbn(self.code)
|
||||
if results["score"] < 3:
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
|
||||
error_message="INVALID_PASSWORD",
|
||||
payload={"email": email},
|
||||
)
|
||||
return
|
||||
|
||||
def __check_signup(self, email):
|
||||
"""Check if sign up is enabled or not and raise exception if not enabled"""
|
||||
|
||||
# Get configuration value
|
||||
(ENABLE_SIGNUP,) = get_configuration_value(
|
||||
[
|
||||
{
|
||||
"key": "ENABLE_SIGNUP",
|
||||
"default": os.environ.get("ENABLE_SIGNUP", "1"),
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
# Check if sign up is disabled and invite is present or not
|
||||
if (
|
||||
ENABLE_SIGNUP == "0"
|
||||
and not WorkspaceMemberInvite.objects.filter(
|
||||
email=email,
|
||||
).exists()
|
||||
):
|
||||
# Raise exception
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["SIGNUP_DISABLED"],
|
||||
error_message="SIGNUP_DISABLED",
|
||||
payload={"email": email},
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def save_user_data(self, user):
|
||||
# Update user details
|
||||
user.last_login_medium = self.provider
|
||||
user.last_active = timezone.now()
|
||||
@@ -116,7 +121,63 @@ class Adapter:
|
||||
user.last_login_uagent = self.request.META.get("HTTP_USER_AGENT")
|
||||
user.token_updated_at = timezone.now()
|
||||
user.save()
|
||||
return user
|
||||
|
||||
def complete_login_or_signup(self):
|
||||
# Get email
|
||||
email = self.user_data.get("email")
|
||||
|
||||
# Sanitize email
|
||||
email = self.sanitize_email(email)
|
||||
|
||||
# Check if the user is present
|
||||
user = User.objects.filter(email=email).first()
|
||||
# Check if sign up case or login
|
||||
is_signup = bool(user)
|
||||
# If user is not present, create a new user
|
||||
if not user:
|
||||
# New user
|
||||
self.__check_signup(email)
|
||||
|
||||
# Initialize user
|
||||
user = User(email=email, username=uuid.uuid4().hex)
|
||||
|
||||
# Check if password is autoset
|
||||
if self.user_data.get("user").get("is_password_autoset"):
|
||||
user.set_password(uuid.uuid4().hex)
|
||||
user.is_password_autoset = True
|
||||
user.is_email_verified = True
|
||||
|
||||
# Validate password
|
||||
else:
|
||||
# Validate password
|
||||
self.validate_password(email)
|
||||
# Set password
|
||||
user.set_password(self.code)
|
||||
user.is_password_autoset = False
|
||||
|
||||
# Set user details
|
||||
avatar = self.user_data.get("user", {}).get("avatar", "")
|
||||
first_name = self.user_data.get("user", {}).get("first_name", "")
|
||||
last_name = self.user_data.get("user", {}).get("last_name", "")
|
||||
user.avatar = avatar if avatar else ""
|
||||
user.first_name = first_name if first_name else ""
|
||||
user.last_name = last_name if last_name else ""
|
||||
user.save()
|
||||
|
||||
# Create profile
|
||||
Profile.objects.create(user=user)
|
||||
|
||||
if not user.is_active:
|
||||
raise AuthenticationException(
|
||||
AUTHENTICATION_ERROR_CODES["USER_ACCOUNT_DEACTIVATED"],
|
||||
error_message="USER_ACCOUNT_DEACTIVATED",
|
||||
)
|
||||
|
||||
# Save user data
|
||||
user = self.save_user_data(user=user)
|
||||
|
||||
# Call callback if present
|
||||
if self.callback:
|
||||
self.callback(
|
||||
user,
|
||||
@@ -124,7 +185,9 @@ class Adapter:
|
||||
self.request,
|
||||
)
|
||||
|
||||
# Create or update account if token data is present
|
||||
if self.token_data:
|
||||
self.create_update_account(user=user)
|
||||
|
||||
# Return user
|
||||
return user
|
||||
|
||||
@@ -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,
|
||||
@@ -58,6 +61,8 @@ AUTHENTICATION_ERROR_CODES = {
|
||||
"ADMIN_USER_DEACTIVATED": 5190,
|
||||
# Rate limit
|
||||
"RATE_LIMIT_EXCEEDED": 5900,
|
||||
# Unknown
|
||||
"AUTHENTICATION_FAILED": 5999,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
@@ -81,11 +77,15 @@ 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"
|
||||
)
|
||||
if self.provider == "google":
|
||||
code = "GOOGLE_OAUTH_PROVIDER_ERROR"
|
||||
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],
|
||||
error_message=str(code),
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
# 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):
|
||||
|
||||
(GITLAB_HOST,) = get_configuration_value(
|
||||
[
|
||||
{
|
||||
"key": "GITLAB_HOST",
|
||||
"default": os.environ.get("GITLAB_HOST", "https://gitlab.com"),
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
if not GITLAB_HOST:
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["GITLAB_NOT_CONFIGURED"],
|
||||
error_message="GITLAB_NOT_CONFIGURED",
|
||||
)
|
||||
|
||||
host = GITLAB_HOST
|
||||
|
||||
token_url = (
|
||||
f"{host}/oauth/token"
|
||||
)
|
||||
userinfo_url = (
|
||||
f"{host}/api/v4/user"
|
||||
)
|
||||
|
||||
provider = "gitlab"
|
||||
scope = "read_user"
|
||||
|
||||
def __init__(self, request, code=None, state=None, callback=None):
|
||||
|
||||
GITLAB_CLIENT_ID, GITLAB_CLIENT_SECRET = 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"),
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
if not (GITLAB_CLIENT_ID and GITLAB_CLIENT_SECRET):
|
||||
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/",
|
||||
|
||||
@@ -4,6 +4,7 @@ from plane.db.models import (
|
||||
WorkspaceMember,
|
||||
WorkspaceMemberInvite,
|
||||
)
|
||||
from plane.utils.cache import invalidate_cache_directly
|
||||
|
||||
|
||||
def process_workspace_project_invitations(user):
|
||||
@@ -26,6 +27,16 @@ def process_workspace_project_invitations(user):
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
[
|
||||
invalidate_cache_directly(
|
||||
path=f"/api/workspaces/{str(workspace_member_invite.workspace.slug)}/members/",
|
||||
url_params=False,
|
||||
user=False,
|
||||
multiple=True,
|
||||
)
|
||||
for workspace_member_invite in workspace_member_invites
|
||||
]
|
||||
|
||||
# Check if user has any project invites
|
||||
project_member_invites = ProjectMemberInvite.objects.filter(
|
||||
email=user.email, accepted=True
|
||||
|
||||
@@ -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', '0066_account_id_token_cycle_logo_props_module_logo_props'),
|
||||
]
|
||||
|
||||
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),
|
||||
),
|
||||
]
|
||||
@@ -12,6 +12,7 @@ from .base import BaseModel
|
||||
|
||||
|
||||
def get_upload_path(instance, filename):
|
||||
filename = filename[:50]
|
||||
if instance.workspace_id is not None:
|
||||
return f"{instance.workspace.id}/{uuid4().hex}-{filename}"
|
||||
return f"user-{uuid4().hex}-{filename}"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -59,6 +59,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", ""),
|
||||
@@ -145,7 +163,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":
|
||||
@@ -216,6 +234,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(
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# Generated by Django 4.2.11 on 2024-06-05 13:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("license", "0002_rename_version_instance_current_version_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="changelog",
|
||||
name="title",
|
||||
field=models.CharField(max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="changelog",
|
||||
name="version",
|
||||
field=models.CharField(max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="instance",
|
||||
name="current_version",
|
||||
field=models.CharField(max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="instance",
|
||||
name="latest_version",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="instance",
|
||||
name="namespace",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="instance",
|
||||
name="product",
|
||||
field=models.CharField(default="plane-ce", max_length=255),
|
||||
),
|
||||
]
|
||||
@@ -21,15 +21,15 @@ class Instance(BaseModel):
|
||||
whitelist_emails = models.TextField(blank=True, null=True)
|
||||
instance_id = models.CharField(max_length=255, unique=True)
|
||||
license_key = models.CharField(max_length=256, null=True, blank=True)
|
||||
current_version = models.CharField(max_length=10)
|
||||
latest_version = models.CharField(max_length=10, null=True, blank=True)
|
||||
current_version = models.CharField(max_length=255)
|
||||
latest_version = models.CharField(max_length=255, null=True, blank=True)
|
||||
product = models.CharField(
|
||||
max_length=50, default=ProductTypes.PLANE_CE.value
|
||||
max_length=255, default=ProductTypes.PLANE_CE.value
|
||||
)
|
||||
domain = models.TextField(blank=True)
|
||||
# Instance specifics
|
||||
last_checked_at = models.DateTimeField()
|
||||
namespace = models.CharField(max_length=50, blank=True, null=True)
|
||||
namespace = models.CharField(max_length=255, blank=True, null=True)
|
||||
# telemetry and support
|
||||
is_telemetry_enabled = models.BooleanField(default=True)
|
||||
is_support_required = models.BooleanField(default=True)
|
||||
@@ -86,9 +86,9 @@ class InstanceConfiguration(BaseModel):
|
||||
class ChangeLog(BaseModel):
|
||||
"""Change Log model to store the release changelogs made in the application."""
|
||||
|
||||
title = models.CharField(max_length=100)
|
||||
title = models.CharField(max_length=255)
|
||||
description = models.TextField(blank=True)
|
||||
version = models.CharField(max_length=100)
|
||||
version = models.CharField(max_length=255)
|
||||
tags = models.JSONField(default=list)
|
||||
release_date = models.DateTimeField(null=True)
|
||||
is_release_candidate = models.BooleanField(default=False)
|
||||
|
||||
@@ -66,7 +66,7 @@ def invalidate_cache_directly(
|
||||
custom_path = path if path is not None else request.get_full_path()
|
||||
auth_header = (
|
||||
None
|
||||
if request.user.is_anonymous
|
||||
if request and request.user.is_anonymous
|
||||
else str(request.user.id) if user else None
|
||||
)
|
||||
key = generate_cache_key(custom_path, auth_header)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"repository": "https://github.com/makeplane/plane.git",
|
||||
"version": "0.20.0",
|
||||
"version": "0.21.0",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/constants",
|
||||
"version": "0.20.0",
|
||||
"version": "0.21.0",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -60,10 +60,13 @@ export enum EAuthErrorCodes {
|
||||
EXPIRED_MAGIC_CODE = "5095",
|
||||
EMAIL_CODE_ATTEMPT_EXHAUSTED = "5100",
|
||||
// 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",
|
||||
@@ -215,6 +218,10 @@ const errorCodeMessages: {
|
||||
},
|
||||
|
||||
// Oauth
|
||||
[EAuthenticationErrorCodes.OAUTH_NOT_CONFIGURED]: {
|
||||
title: `OAuth not configured`,
|
||||
message: () => `OAuth not configured. Please contact your administrator.`,
|
||||
},
|
||||
[EAuthenticationErrorCodes.GOOGLE_NOT_CONFIGURED]: {
|
||||
title: `Google not configured`,
|
||||
message: () => `Google not configured. Please contact your administrator.`,
|
||||
@@ -223,6 +230,10 @@ const errorCodeMessages: {
|
||||
title: `GitHub not configured`,
|
||||
message: () => `GitHub not configured. Please contact your administrator.`,
|
||||
},
|
||||
[EAuthenticationErrorCodes.GITLAB_NOT_CONFIGURED]: {
|
||||
title: `GitLab not configured`,
|
||||
message: () => `GitLab not configured. Please contact your administrator.`,
|
||||
},
|
||||
[EAuthenticationErrorCodes.GOOGLE_OAUTH_PROVIDER_ERROR]: {
|
||||
title: `Google OAuth provider error`,
|
||||
message: () => `Google OAuth provider error. Please try again.`,
|
||||
@@ -231,6 +242,10 @@ const errorCodeMessages: {
|
||||
title: `GitHub OAuth provider error`,
|
||||
message: () => `GitHub OAuth provider error. Please try again.`,
|
||||
},
|
||||
[EAuthenticationErrorCodes.GITLAB_OAUTH_PROVIDER_ERROR]: {
|
||||
title: `GitLab OAuth provider error`,
|
||||
message: () => `GitLab OAuth provider error. Please try again.`,
|
||||
},
|
||||
|
||||
// Reset Password
|
||||
[EAuthenticationErrorCodes.INVALID_PASSWORD_TOKEN]: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/editor-core",
|
||||
"version": "0.20.0",
|
||||
"version": "0.21.0",
|
||||
"description": "Core Editor that powers Plane",
|
||||
"private": true,
|
||||
"main": "./dist/index.mjs",
|
||||
|
||||
@@ -112,7 +112,7 @@ export const useEditor = ({
|
||||
if (value === null || value === undefined) return;
|
||||
if (editor && !editor.isDestroyed && !editor.storage.image.uploadInProgress) {
|
||||
try {
|
||||
editor.commands.setContent(value);
|
||||
editor.commands.setContent(value, false, { preserveWhitespace: "full" });
|
||||
const currentSavedSelection = savedSelectionRef.current;
|
||||
if (currentSavedSelection) {
|
||||
const docLength = editor.state.doc.content.size;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { startImageUpload } from "src/ui/plugins/image/image-upload-handler";
|
||||
import { findTableAncestor } from "src/lib/utils";
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
import { UploadImage } from "src/types/upload-image";
|
||||
import { replaceCodeWithText } from "src/ui/extensions/code/utils/replace-code-block-with-text";
|
||||
|
||||
export const setText = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).clearNodes().run();
|
||||
@@ -54,69 +55,11 @@ export const toggleUnderline = (editor: Editor, range?: Range) => {
|
||||
else editor.chain().focus().toggleUnderline().run();
|
||||
};
|
||||
|
||||
const replaceCodeBlockWithContent = (editor: Editor) => {
|
||||
try {
|
||||
const { schema } = editor.state;
|
||||
const { paragraph } = schema.nodes;
|
||||
let replaced = false;
|
||||
|
||||
const replaceCodeBlock = (from: number, to: number, textContent: string) => {
|
||||
const docSize = editor.state.doc.content.size;
|
||||
|
||||
if (from < 0 || to > docSize || from > to) {
|
||||
console.error("Invalid range for replacement: ", from, to, "in a document of size", docSize);
|
||||
return;
|
||||
}
|
||||
|
||||
// split the textContent by new lines to handle each line as a separate paragraph
|
||||
const lines = textContent.split(/\r?\n/);
|
||||
|
||||
const tr = editor.state.tr;
|
||||
|
||||
// Calculate the position for inserting the first paragraph
|
||||
let insertPos = from;
|
||||
|
||||
// Remove the code block first
|
||||
tr.delete(from, to);
|
||||
|
||||
// For each line, create a paragraph node and insert it
|
||||
lines.forEach((line) => {
|
||||
const paragraphNode = paragraph.create({}, schema.text(line));
|
||||
tr.insert(insertPos, paragraphNode);
|
||||
// Update insertPos for the next insertion
|
||||
insertPos += paragraphNode.nodeSize;
|
||||
});
|
||||
|
||||
// Dispatch the transaction
|
||||
editor.view.dispatch(tr);
|
||||
replaced = true;
|
||||
};
|
||||
|
||||
editor.state.doc.nodesBetween(editor.state.selection.from, editor.state.selection.to, (node, pos) => {
|
||||
if (node.type === schema.nodes.codeBlock) {
|
||||
const startPos = pos;
|
||||
const endPos = pos + node.nodeSize;
|
||||
const textContent = node.textContent;
|
||||
if (textContent.length === 0) {
|
||||
editor.chain().focus().toggleCodeBlock().run();
|
||||
}
|
||||
replaceCodeBlock(startPos, endPos, textContent);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (!replaced) {
|
||||
console.log("No code block to replace.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("An error occurred while replacing code block content:", error);
|
||||
}
|
||||
};
|
||||
|
||||
export const toggleCodeBlock = (editor: Editor, range?: Range) => {
|
||||
try {
|
||||
// if it's a code block, replace it with the code with paragraphs
|
||||
if (editor.isActive("codeBlock")) {
|
||||
replaceCodeBlockWithContent(editor);
|
||||
replaceCodeWithText(editor);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -124,11 +67,16 @@ export const toggleCodeBlock = (editor: Editor, range?: Range) => {
|
||||
const text = editor.state.doc.textBetween(from, to, "\n");
|
||||
const isMultiline = text.includes("\n");
|
||||
|
||||
// if the selection is not a range i.e. empty, then simply convert it into a code block
|
||||
if (editor.state.selection.empty) {
|
||||
editor.chain().focus().toggleCodeBlock().run();
|
||||
} else if (isMultiline) {
|
||||
// if the selection is multiline, then also replace the text content with
|
||||
// a code block
|
||||
editor.chain().focus().deleteRange({ from, to }).insertContentAt(from, `\`\`\`\n${text}\n\`\`\``).run();
|
||||
} else {
|
||||
// if the selection is single line, then simply convert it into inline
|
||||
// code
|
||||
editor.chain().focus().toggleCode().run();
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -110,6 +110,11 @@ ul[data-type="taskList"] li > label input[type="checkbox"]:checked:hover {
|
||||
}
|
||||
}
|
||||
|
||||
/* the p tag just after the ul tag */
|
||||
ul[data-type="taskList"] + p {
|
||||
margin-top: 0.4rem !important;
|
||||
}
|
||||
|
||||
ul[data-type="taskList"] li > label input[type="checkbox"] {
|
||||
position: relative;
|
||||
-webkit-appearance: none;
|
||||
@@ -152,6 +157,10 @@ ul[data-type="taskList"] li > label input[type="checkbox"] {
|
||||
}
|
||||
}
|
||||
|
||||
ul[data-type="taskList"] li > div > p {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
ul[data-type="taskList"] li[data-checked="true"] > div > p {
|
||||
color: rgb(var(--color-text-400));
|
||||
text-decoration: line-through;
|
||||
|
||||
@@ -33,7 +33,7 @@ export const CustomCodeInlineExtension = Mark.create<CodeOptions>({
|
||||
return {
|
||||
HTMLAttributes: {
|
||||
class:
|
||||
"rounded bg-custom-background-80 px-1 py-[2px] font-mono font-medium text-orange-500 border-[0.5px] border-custom-border-200 text-sm",
|
||||
"rounded bg-custom-background-80 px-1 py-[2px] font-mono font-medium text-orange-500 border-[0.5px] border-custom-border-200",
|
||||
spellcheck: "false",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Editor, findParentNode } from "@tiptap/core";
|
||||
|
||||
type ReplaceCodeBlockParams = {
|
||||
editor: Editor;
|
||||
from: number;
|
||||
to: number;
|
||||
textContent: string;
|
||||
cursorPosInsideCodeblock: number;
|
||||
};
|
||||
|
||||
export function replaceCodeWithText(editor: Editor): void {
|
||||
try {
|
||||
const { from, to } = editor.state.selection;
|
||||
const cursorPosInsideCodeblock = from;
|
||||
let replaced = false;
|
||||
|
||||
editor.state.doc.nodesBetween(from, to, (node, pos) => {
|
||||
if (node.type === editor.state.schema.nodes.codeBlock) {
|
||||
const startPos = pos;
|
||||
const endPos = pos + node.nodeSize;
|
||||
const textContent = node.textContent;
|
||||
|
||||
if (textContent.length === 0) {
|
||||
editor.chain().focus().toggleCodeBlock().run();
|
||||
} else {
|
||||
transformCodeBlockToParagraphs({
|
||||
editor,
|
||||
from: startPos,
|
||||
to: endPos,
|
||||
textContent,
|
||||
cursorPosInsideCodeblock,
|
||||
});
|
||||
}
|
||||
|
||||
replaced = true;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (!replaced) {
|
||||
console.log("No code block to replace.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("An error occurred while replacing code block content:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function transformCodeBlockToParagraphs({
|
||||
editor,
|
||||
from,
|
||||
to,
|
||||
textContent,
|
||||
cursorPosInsideCodeblock,
|
||||
}: ReplaceCodeBlockParams): void {
|
||||
const { schema } = editor.state;
|
||||
const { paragraph } = schema.nodes;
|
||||
const docSize = editor.state.doc.content.size;
|
||||
|
||||
if (from < 0 || to > docSize || from > to) {
|
||||
console.error("Invalid range for replacement: ", from, to, "in a document of size", docSize);
|
||||
return;
|
||||
}
|
||||
|
||||
// Split the textContent by new lines to handle each line as a separate paragraph for Windows (\r\n) and Unix (\n)
|
||||
const lines = textContent.split(/\r?\n/);
|
||||
const tr = editor.state.tr;
|
||||
let insertPos = from;
|
||||
|
||||
// Remove the code block first
|
||||
tr.delete(from, to);
|
||||
|
||||
// For each line, create a paragraph node and insert it
|
||||
lines.forEach((line) => {
|
||||
// if the line is empty, create a paragraph node with no content
|
||||
const paragraphNode = line.length === 0 ? paragraph.create({}) : paragraph.create({}, schema.text(line));
|
||||
tr.insert(insertPos, paragraphNode);
|
||||
insertPos += paragraphNode.nodeSize;
|
||||
});
|
||||
|
||||
// Now persist the focus to the converted paragraph
|
||||
const parentNodeOffset = findParentNode((node) => node.type === schema.nodes.codeBlock)(editor.state.selection)?.pos;
|
||||
|
||||
if (parentNodeOffset === undefined) throw new Error("Invalid code block offset");
|
||||
|
||||
const lineNumber = getLineNumber(textContent, cursorPosInsideCodeblock, parentNodeOffset);
|
||||
const cursorPosOutsideCodeblock = cursorPosInsideCodeblock + (lineNumber - 1);
|
||||
|
||||
editor.view.dispatch(tr);
|
||||
editor.chain().focus(cursorPosOutsideCodeblock).run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the line number where the cursor is located inside the code block.
|
||||
* Assumes the indexing of the content inside the code block is like ProseMirror's indexing.
|
||||
*
|
||||
* @param {string} textContent - The content of the code block.
|
||||
* @param {number} cursorPosition - The absolute cursor position in the document.
|
||||
* @param {number} codeBlockNodePos - The starting position of the code block node in the document.
|
||||
* @returns {number} The 1-based line number where the cursor is located.
|
||||
*/
|
||||
function getLineNumber(textContent: string, cursorPosition: number, codeBlockNodePos: number): number {
|
||||
// Split the text content into lines, handling both Unix and Windows newlines
|
||||
const lines = textContent.split(/\r?\n/);
|
||||
const cursorPosInsideCodeblockRelative = cursorPosition - codeBlockNodePos;
|
||||
|
||||
let startPosition = 0;
|
||||
let lineNumber = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
// Calculate the end position of the current line
|
||||
const endPosition = startPosition + lines[i].length + 1; // +1 for the newline character
|
||||
|
||||
// Check if the cursor position is within the current line
|
||||
if (cursorPosInsideCodeblockRelative >= startPosition && cursorPosInsideCodeblockRelative <= endPosition) {
|
||||
lineNumber = i + 1; // Line numbers are 1-based
|
||||
break;
|
||||
}
|
||||
|
||||
// Update the start position for the next line
|
||||
startPosition = endPosition;
|
||||
}
|
||||
|
||||
return lineNumber;
|
||||
}
|
||||
@@ -72,7 +72,7 @@ const getPrevListDepth = (typeOrName: string, state: EditorState) => {
|
||||
// Traverse up the document structure from the adjusted position
|
||||
for (let d = resolvedPos.depth; d > 0; d--) {
|
||||
const node = resolvedPos.node(d);
|
||||
if (node.type.name === "bulletList" || node.type.name === "orderedList") {
|
||||
if (node.type.name === "bulletList" || node.type.name === "orderedList" || node.type.name === "taskList") {
|
||||
// Increment depth for each list ancestor found
|
||||
depth++;
|
||||
}
|
||||
@@ -146,6 +146,8 @@ export const handleBackspace = (editor: Editor, name: string, parentListTypes: s
|
||||
if (!isAtStartOfNode(editor.state)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// is the paragraph node inside of the current list item (maybe with a hard break)
|
||||
const isParaSibling = isCurrentParagraphASibling(editor.state);
|
||||
const isCurrentListItemSublist = prevListIsHigher(name, editor.state);
|
||||
const listItemPos = findListItemPos(name, editor.state);
|
||||
@@ -306,7 +308,10 @@ const isCurrentParagraphASibling = (state: EditorState): boolean => {
|
||||
const currentParagraphNode = $from.parent; // Get the current node where the selection is.
|
||||
|
||||
// Ensure we're in a paragraph and the parent is a list item.
|
||||
if (currentParagraphNode.type.name === "paragraph" && listItemNode.type.name === "listItem") {
|
||||
if (
|
||||
currentParagraphNode.type.name === "paragraph" &&
|
||||
(listItemNode.type.name === "listItem" || listItemNode.type.name === "taskItem")
|
||||
) {
|
||||
let paragraphNodesCount = 0;
|
||||
listItemNode.forEach((child) => {
|
||||
if (child.type.name === "paragraph") {
|
||||
@@ -327,16 +332,19 @@ export function isCursorInSubList(editor: Editor) {
|
||||
|
||||
// Check if the current node is a list item
|
||||
const listItem = editor.schema.nodes.listItem;
|
||||
const taskItem = editor.schema.nodes.taskItem;
|
||||
|
||||
// Traverse up the document tree from the current position
|
||||
for (let depth = $from.depth; depth > 0; depth--) {
|
||||
const node = $from.node(depth);
|
||||
if (node.type === listItem) {
|
||||
if (node.type === listItem || node.type === taskItem) {
|
||||
// If the parent of the list item is also a list, it's a sub-list
|
||||
const parent = $from.node(depth - 1);
|
||||
if (
|
||||
parent &&
|
||||
(parent.type === editor.schema.nodes.bulletList || parent.type === editor.schema.nodes.orderedList)
|
||||
(parent.type === editor.schema.nodes.bulletList ||
|
||||
parent.type === editor.schema.nodes.orderedList ||
|
||||
parent.type === editor.schema.nodes.taskList)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ export const ListKeymap = ({ tabIndex }: { tabIndex?: number }) =>
|
||||
|
||||
return handled;
|
||||
} catch (e) {
|
||||
console.log("error in handling Backspac:", e);
|
||||
console.log("Error in handling Delete:", e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
@@ -104,7 +104,7 @@ export const ListKeymap = ({ tabIndex }: { tabIndex?: number }) =>
|
||||
|
||||
return handled;
|
||||
} catch (e) {
|
||||
console.log("error in handling Backspac:", e);
|
||||
console.log("Error in handling Backspace:", e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15,9 +15,7 @@ declare module "@tiptap/core" {
|
||||
}
|
||||
}
|
||||
|
||||
function autoJoin(tr: Transaction, newTr: Transaction, nodeType: NodeType) {
|
||||
if (!tr.isGeneric) return false;
|
||||
|
||||
function autoJoin(tr: Transaction, newTr: Transaction, nodeTypes: NodeType[]) {
|
||||
// Find all ranges where we might want to join.
|
||||
const ranges: Array<number> = [];
|
||||
for (let i = 0; i < tr.mapping.maps.length; i++) {
|
||||
@@ -28,7 +26,7 @@ function autoJoin(tr: Transaction, newTr: Transaction, nodeType: NodeType) {
|
||||
|
||||
// Figure out which joinable points exist inside those ranges,
|
||||
// by checking all node boundaries in their parent nodes.
|
||||
const joinable = [];
|
||||
const joinable: number[] = [];
|
||||
for (let i = 0; i < ranges.length; i += 2) {
|
||||
const from = ranges[i],
|
||||
to = ranges[i + 1];
|
||||
@@ -40,7 +38,7 @@ function autoJoin(tr: Transaction, newTr: Transaction, nodeType: NodeType) {
|
||||
if (!after) break;
|
||||
if (index && joinable.indexOf(pos) == -1) {
|
||||
const before = parent.child(index - 1);
|
||||
if (before.type == after.type && before.type === nodeType) joinable.push(pos);
|
||||
if (before.type == after.type && nodeTypes.includes(before.type)) joinable.push(pos);
|
||||
}
|
||||
pos += after.nodeSize;
|
||||
}
|
||||
@@ -88,25 +86,15 @@ export const CustomKeymap = Extension.create({
|
||||
// Create a new transaction.
|
||||
const newTr = newState.tr;
|
||||
|
||||
let joined = false;
|
||||
for (const transaction of transactions) {
|
||||
const anotherJoin = autoJoin(transaction, newTr, newState.schema.nodes["orderedList"]);
|
||||
joined = anotherJoin || joined;
|
||||
}
|
||||
if (joined) {
|
||||
return newTr;
|
||||
}
|
||||
},
|
||||
}),
|
||||
new Plugin({
|
||||
key: new PluginKey("unordered-list-merging"),
|
||||
appendTransaction(transactions, oldState, newState) {
|
||||
// Create a new transaction.
|
||||
const newTr = newState.tr;
|
||||
const joinableNodes = [
|
||||
newState.schema.nodes["orderedList"],
|
||||
newState.schema.nodes["taskList"],
|
||||
newState.schema.nodes["bulletList"],
|
||||
];
|
||||
|
||||
let joined = false;
|
||||
for (const transaction of transactions) {
|
||||
const anotherJoin = autoJoin(transaction, newTr, newState.schema.nodes["bulletList"]);
|
||||
const anotherJoin = autoJoin(transaction, newTr, joinableNodes);
|
||||
joined = anotherJoin || joined;
|
||||
}
|
||||
if (joined) {
|
||||
|
||||
@@ -50,9 +50,25 @@ export async function startImageUpload(
|
||||
};
|
||||
|
||||
try {
|
||||
const fileNameTrimmed = trimFileName(file.name);
|
||||
const fileWithTrimmedName = new File([file], fileNameTrimmed, { type: file.type });
|
||||
|
||||
const resolvedPos = view.state.doc.resolve(pos ?? 0);
|
||||
const nodeBefore = resolvedPos.nodeBefore;
|
||||
|
||||
// if the image is at the start of the line i.e. when nodeBefore is null
|
||||
if (nodeBefore === null) {
|
||||
if (pos) {
|
||||
// so that the image is not inserted at the next line, else incase the
|
||||
// image is inserted at any line where there's some content, the
|
||||
// position is kept as it is to be inserted at the next line
|
||||
pos -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
view.focus();
|
||||
|
||||
const src = await uploadAndValidateImage(file, uploadFile);
|
||||
const src = await uploadAndValidateImage(fileWithTrimmedName, uploadFile);
|
||||
|
||||
if (src == null) {
|
||||
throw new Error("Resolved image URL is undefined.");
|
||||
@@ -112,3 +128,14 @@ async function uploadAndValidateImage(file: File, uploadFile: UploadImage): Prom
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function trimFileName(fileName: string, maxLength = 100) {
|
||||
if (fileName.length > maxLength) {
|
||||
const extension = fileName.split(".").pop();
|
||||
const nameWithoutExtension = fileName.slice(0, -(extension?.length ?? 0 + 1));
|
||||
const allowedNameLength = maxLength - (extension?.length ?? 0) - 1; // -1 for the dot
|
||||
return `${nameWithoutExtension.slice(0, allowedNameLength)}.${extension}`;
|
||||
}
|
||||
|
||||
return fileName;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/document-editor",
|
||||
"version": "0.20.0",
|
||||
"version": "0.21.0",
|
||||
"description": "Package that powers Plane's Pages Editor",
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/editor-extensions",
|
||||
"version": "0.20.0",
|
||||
"version": "0.21.0",
|
||||
"description": "Package that powers Plane's Editor with extensions",
|
||||
"private": true,
|
||||
"main": "./dist/index.mjs",
|
||||
|
||||
@@ -69,7 +69,6 @@ const Command = Extension.create<SlashCommandOptions>({
|
||||
|
||||
return true;
|
||||
},
|
||||
allowSpaces: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/lite-text-editor",
|
||||
"version": "0.20.0",
|
||||
"version": "0.21.0",
|
||||
"description": "Package that powers Plane's Comment Editor",
|
||||
"private": true,
|
||||
"main": "./dist/index.mjs",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/rich-text-editor",
|
||||
"version": "0.20.0",
|
||||
"version": "0.21.0",
|
||||
"description": "Rich Text Editor that powers Plane",
|
||||
"private": true,
|
||||
"main": "./dist/index.mjs",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "eslint-config-custom",
|
||||
"private": true,
|
||||
"version": "0.20.0",
|
||||
"version": "0.21.0",
|
||||
"main": "index.js",
|
||||
"license": "MIT",
|
||||
"devDependencies": {},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tailwind-config-custom",
|
||||
"version": "0.20.0",
|
||||
"version": "0.21.0",
|
||||
"description": "common tailwind configuration across monorepo",
|
||||
"main": "index.js",
|
||||
"private": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tsconfig",
|
||||
"version": "0.20.0",
|
||||
"version": "0.21.0",
|
||||
"private": true,
|
||||
"files": [
|
||||
"base.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/types",
|
||||
"version": "0.20.0",
|
||||
"version": "0.21.0",
|
||||
"private": true,
|
||||
"main": "./src/index.d.ts"
|
||||
}
|
||||
|
||||
+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;
|
||||
|
||||
+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
+2
-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;
|
||||
@@ -128,6 +128,7 @@ export interface IUserActivityResponse {
|
||||
prev_page_results: boolean;
|
||||
results: IIssueActivity[];
|
||||
total_pages: number;
|
||||
total_results: number;
|
||||
}
|
||||
|
||||
export type UserAuth = {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@plane/ui",
|
||||
"description": "UI components shared across multiple apps internally",
|
||||
"private": true,
|
||||
"version": "0.20.0",
|
||||
"version": "0.21.0",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -20,12 +20,15 @@
|
||||
"postcss": "postcss styles/globals.css -o styles/output.css --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.1.10",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
|
||||
"@blueprintjs/core": "^4.16.3",
|
||||
"@blueprintjs/popover2": "^1.13.3",
|
||||
"@headlessui/react": "^1.7.17",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"clsx": "^2.0.0",
|
||||
"emoji-picker-react": "^4.5.16",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.379.0",
|
||||
"react-color": "^2.19.3",
|
||||
"react-dom": "^18.2.0",
|
||||
|
||||
@@ -7,10 +7,11 @@ export type TControlLink = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||
target?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
draggable?: boolean;
|
||||
};
|
||||
|
||||
export const ControlLink = React.forwardRef<HTMLAnchorElement, TControlLink>((props, ref) => {
|
||||
const { href, onClick, children, target = "_self", disabled = false, className, ...rest } = props;
|
||||
const { href, onClick, children, target = "_self", disabled = false, className, draggable = false, ...rest } = props;
|
||||
const LEFT_CLICK_EVENT_CODE = 0;
|
||||
|
||||
const handleOnClick = (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
|
||||
@@ -33,7 +34,15 @@ export const ControlLink = React.forwardRef<HTMLAnchorElement, TControlLink>((pr
|
||||
if (disabled) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<a href={href} target={target} onClick={handleOnClick} {...rest} ref={ref} className={className}>
|
||||
<a
|
||||
href={href}
|
||||
target={target}
|
||||
onClick={handleOnClick}
|
||||
{...rest}
|
||||
ref={ref}
|
||||
className={className}
|
||||
draggable={draggable}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
Below is a detailed list of the props included:
|
||||
|
||||
### Root Props
|
||||
- value: string | string[]; - Current selected value.
|
||||
- onChange: (value: string | string []) => void; - Callback function for handling value changes.
|
||||
- options: TDropdownOption[] | undefined; - Array of options.
|
||||
- onOpen?: () => void; - Callback function triggered when the dropdown opens.
|
||||
- onClose?: () => void; - Callback function triggered when the dropdown closes.
|
||||
- containerClassName?: (isOpen: boolean) => string; - Function to return the class name for the container based on the open state.
|
||||
- tabIndex?: number; - Sets the tab index for the dropdown.
|
||||
- placement?: Placement; - Determines the placement of the dropdown (e.g., top, bottom, left, right).
|
||||
- disabled?: boolean; - Disables the dropdown if set to true.
|
||||
|
||||
---
|
||||
|
||||
### Button Props
|
||||
- buttonContent?: (isOpen: boolean) => React.ReactNode; - Function to render the content of the button based on the open state.
|
||||
- buttonContainerClassName?: string; - Class name for the button container.
|
||||
- buttonClassName?: string; - Class name for the button itself.
|
||||
|
||||
---
|
||||
|
||||
### Input Props
|
||||
- disableSearch?: boolean; - Disables the search input if set to true.
|
||||
- inputPlaceholder?: string; - Placeholder text for the search input.
|
||||
- inputClassName?: string; - Class name for the search input.
|
||||
- inputIcon?: React.ReactNode; - Icon to be displayed in the search input.
|
||||
- inputContainerClassName?: string; - Class name for the search input container.
|
||||
|
||||
---
|
||||
|
||||
### Options Props
|
||||
- keyExtractor: (option: TDropdownOption) => string; - Function to extract the key from each option.
|
||||
- optionsContainerClassName?: string; - Class name for the options container.
|
||||
- queryArray: string[]; - Array of strings to be used for querying the options.
|
||||
- sortByKey: string; - Key to sort the options by.
|
||||
- firstItem?: (optionValue: string) => boolean; - Function to determine if an option should be the first item.
|
||||
- renderItem?: ({ value, selected }: { value: string; selected: boolean }) => React.ReactNode; - Function to render each option.
|
||||
- loader?: React.ReactNode; - Loader element to be displayed while options are being loaded.
|
||||
- disableSorting?: boolean; - Disables sorting of the options if set to true.
|
||||
|
||||
---
|
||||
|
||||
These properties offer extensive control over the dropdown's behavior and presentation, making it a highly versatile component suitable for various scenarios.
|
||||
@@ -0,0 +1,38 @@
|
||||
import React, { Fragment } from "react";
|
||||
// headless ui
|
||||
import { Combobox } from "@headlessui/react";
|
||||
// helper
|
||||
import { cn } from "../../../helpers";
|
||||
import { IMultiSelectDropdownButton, ISingleSelectDropdownButton } from "../dropdown";
|
||||
|
||||
export const DropdownButton: React.FC<IMultiSelectDropdownButton | ISingleSelectDropdownButton> = (props) => {
|
||||
const {
|
||||
isOpen,
|
||||
buttonContent,
|
||||
buttonClassName,
|
||||
buttonContainerClassName,
|
||||
handleOnClick,
|
||||
value,
|
||||
setReferenceElement,
|
||||
disabled,
|
||||
} = props;
|
||||
return (
|
||||
<Combobox.Button as={Fragment}>
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn(
|
||||
"clickable block h-full max-w-full outline-none",
|
||||
{
|
||||
"cursor-not-allowed text-custom-text-200": disabled,
|
||||
"cursor-pointer": !disabled,
|
||||
},
|
||||
buttonContainerClassName
|
||||
)}
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
{buttonContent ? <>{buttonContent(isOpen)}</> : <span className={cn("", buttonClassName)}>{value}</span>}
|
||||
</button>
|
||||
</Combobox.Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./input-search";
|
||||
export * from "./button";
|
||||
export * from "./options";
|
||||
export * from "./loader";
|
||||
@@ -0,0 +1,58 @@
|
||||
import React, { FC, useEffect, useRef } from "react";
|
||||
// headless ui
|
||||
import { Combobox } from "@headlessui/react";
|
||||
// icons
|
||||
import { Search } from "lucide-react";
|
||||
// helpers
|
||||
import { cn } from "../../../helpers";
|
||||
|
||||
interface IInputSearch {
|
||||
isOpen: boolean;
|
||||
query: string;
|
||||
updateQuery: (query: string) => void;
|
||||
inputIcon?: React.ReactNode;
|
||||
inputContainerClassName?: string;
|
||||
inputClassName?: string;
|
||||
inputPlaceholder?: string;
|
||||
}
|
||||
|
||||
export const InputSearch: FC<IInputSearch> = (props) => {
|
||||
const { isOpen, query, updateQuery, inputIcon, inputContainerClassName, inputClassName, inputPlaceholder } = props;
|
||||
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const searchInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (query !== "" && e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
updateQuery("");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
inputRef.current && inputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2",
|
||||
inputContainerClassName
|
||||
)}
|
||||
>
|
||||
{inputIcon ? <>{inputIcon}</> : <Search className="h-4 w-4 text-custom-text-300" aria-hidden="true" />}
|
||||
<Combobox.Input
|
||||
as="input"
|
||||
ref={inputRef}
|
||||
className={cn(
|
||||
"w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none",
|
||||
inputClassName
|
||||
)}
|
||||
value={query}
|
||||
onChange={(e) => updateQuery(e.target.value)}
|
||||
placeholder={inputPlaceholder ?? "Search"}
|
||||
onKeyDown={searchInputKeyDown}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
|
||||
export const DropdownOptionsLoader = () => (
|
||||
<div className="flex flex-col gap-1 animate-pulse">
|
||||
{Array.from({ length: 6 }, (_, i) => (
|
||||
<div key={i} className="flex h-[1.925rem] w-full rounded px-1 py-1.5 bg-custom-background-90" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,88 @@
|
||||
import React from "react";
|
||||
// headless ui
|
||||
import { Combobox } from "@headlessui/react";
|
||||
// icons
|
||||
import { Check } from "lucide-react";
|
||||
// components
|
||||
import { DropdownOptionsLoader, InputSearch } from ".";
|
||||
// helpers
|
||||
import { cn } from "../../../helpers";
|
||||
// types
|
||||
import { IMultiSelectDropdownOptions, ISingleSelectDropdownOptions } from "../dropdown";
|
||||
|
||||
export const DropdownOptions: React.FC<IMultiSelectDropdownOptions | ISingleSelectDropdownOptions> = (props) => {
|
||||
const {
|
||||
isOpen,
|
||||
query,
|
||||
setQuery,
|
||||
inputIcon,
|
||||
inputPlaceholder,
|
||||
inputClassName,
|
||||
inputContainerClassName,
|
||||
disableSearch,
|
||||
keyExtractor,
|
||||
options,
|
||||
value,
|
||||
renderItem,
|
||||
loader,
|
||||
} = props;
|
||||
return (
|
||||
<>
|
||||
{!disableSearch && (
|
||||
<InputSearch
|
||||
isOpen={isOpen}
|
||||
query={query}
|
||||
updateQuery={(query) => setQuery(query)}
|
||||
inputIcon={inputIcon}
|
||||
inputPlaceholder={inputPlaceholder}
|
||||
inputClassName={inputClassName}
|
||||
inputContainerClassName={inputContainerClassName}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
||||
<>
|
||||
{options ? (
|
||||
options.length > 0 ? (
|
||||
options?.map((option) => (
|
||||
<Combobox.Option
|
||||
key={keyExtractor(option)}
|
||||
value={option.data[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",
|
||||
{
|
||||
"bg-custom-background-80": active,
|
||||
"text-custom-text-100": selected,
|
||||
"text-custom-text-200": !selected,
|
||||
},
|
||||
option.className && option.className({ active, selected })
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
{renderItem ? (
|
||||
<>{renderItem({ value: option.data[option.value], selected })}</>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-grow truncate">{value}</span>
|
||||
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))
|
||||
) : (
|
||||
<p className="px-1.5 py-1 italic text-custom-text-400">No matching results</p>
|
||||
)
|
||||
) : loader ? (
|
||||
<> {loader} </>
|
||||
) : (
|
||||
<DropdownOptionsLoader />
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
import { Placement } from "@popperjs/core";
|
||||
|
||||
export interface IDropdown {
|
||||
// root props
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
containerClassName?: (isOpen: boolean) => string;
|
||||
tabIndex?: number;
|
||||
placement?: Placement;
|
||||
disabled?: boolean;
|
||||
|
||||
// button props
|
||||
buttonContent?: (isOpen: boolean) => React.ReactNode;
|
||||
buttonContainerClassName?: string;
|
||||
buttonClassName?: string;
|
||||
|
||||
// input props
|
||||
disableSearch?: boolean;
|
||||
inputPlaceholder?: string;
|
||||
inputClassName?: string;
|
||||
inputIcon?: React.ReactNode;
|
||||
inputContainerClassName?: string;
|
||||
|
||||
// options props
|
||||
keyExtractor: (option: TDropdownOption) => string;
|
||||
optionsContainerClassName?: string;
|
||||
queryArray: string[];
|
||||
sortByKey: string;
|
||||
firstItem?: (optionValue: string) => boolean;
|
||||
renderItem?: ({ value, selected }: { value: string; selected: boolean }) => React.ReactNode;
|
||||
loader?: React.ReactNode;
|
||||
disableSorting?: boolean;
|
||||
}
|
||||
|
||||
export interface TDropdownOption {
|
||||
data: any;
|
||||
value: string;
|
||||
className?: ({ active, selected }: { active: boolean; selected: boolean }) => string;
|
||||
}
|
||||
|
||||
export interface IMultiSelectDropdown extends IDropdown {
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
options: TDropdownOption[] | undefined;
|
||||
}
|
||||
|
||||
export interface ISingleSelectDropdown extends IDropdown {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: TDropdownOption[] | undefined;
|
||||
}
|
||||
|
||||
export interface IDropdownButton {
|
||||
isOpen: boolean;
|
||||
buttonContent?: (isOpen: boolean) => React.ReactNode;
|
||||
buttonClassName?: string;
|
||||
buttonContainerClassName?: string;
|
||||
handleOnClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
setReferenceElement: (element: HTMLButtonElement | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface IMultiSelectDropdownButton extends IDropdownButton {
|
||||
value: string[];
|
||||
}
|
||||
|
||||
export interface ISingleSelectDropdownButton extends IDropdownButton {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface IDropdownOptions {
|
||||
isOpen: boolean;
|
||||
query: string;
|
||||
setQuery: (query: string) => void;
|
||||
|
||||
inputPlaceholder?: string;
|
||||
inputClassName?: string;
|
||||
inputIcon?: React.ReactNode;
|
||||
inputContainerClassName?: string;
|
||||
disableSearch?: boolean;
|
||||
|
||||
keyExtractor: (option: TDropdownOption) => string;
|
||||
renderItem: (({ value, selected }: { value: string; selected: boolean }) => React.ReactNode) | undefined;
|
||||
options: TDropdownOption[] | undefined;
|
||||
loader?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface IMultiSelectDropdownOptions extends IDropdownOptions {
|
||||
value: string[];
|
||||
}
|
||||
|
||||
export interface ISingleSelectDropdownOptions extends IDropdownOptions {
|
||||
value: string;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./common";
|
||||
export * from "./multi-select";
|
||||
export * from "./single-select";
|
||||
@@ -0,0 +1,167 @@
|
||||
import React, { FC, useMemo, useRef, useState } from "react";
|
||||
import sortBy from "lodash/sortBy";
|
||||
// headless ui
|
||||
import { Combobox } from "@headlessui/react";
|
||||
// popper-js
|
||||
import { usePopper } from "react-popper";
|
||||
// components
|
||||
import { DropdownButton } from "./common";
|
||||
import { DropdownOptions } from "./common/options";
|
||||
// hooks
|
||||
import { useDropdownKeyPressed } from "../hooks/use-dropdown-key-pressed";
|
||||
import useOutsideClickDetector from "../hooks/use-outside-click-detector";
|
||||
// helper
|
||||
import { cn } from "../../helpers";
|
||||
// types
|
||||
import { IMultiSelectDropdown } from "./dropdown";
|
||||
|
||||
export const MultiSelectDropdown: FC<IMultiSelectDropdown> = (props) => {
|
||||
const {
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
onOpen,
|
||||
onClose,
|
||||
containerClassName,
|
||||
tabIndex,
|
||||
placement,
|
||||
disabled,
|
||||
buttonContent,
|
||||
buttonContainerClassName,
|
||||
buttonClassName,
|
||||
disableSearch,
|
||||
inputPlaceholder,
|
||||
inputClassName,
|
||||
inputIcon,
|
||||
inputContainerClassName,
|
||||
keyExtractor,
|
||||
optionsContainerClassName,
|
||||
queryArray,
|
||||
sortByKey,
|
||||
firstItem,
|
||||
renderItem,
|
||||
loader = false,
|
||||
disableSorting,
|
||||
} = props;
|
||||
|
||||
// states
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
// refs
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
// popper-js refs
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
|
||||
// popper-js init
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: placement ?? "bottom-start",
|
||||
modifiers: [
|
||||
{
|
||||
name: "preventOverflow",
|
||||
options: {
|
||||
padding: 12,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// handlers
|
||||
const toggleDropdown = () => {
|
||||
if (!isOpen) onOpen?.();
|
||||
setIsOpen((prevIsOpen) => !prevIsOpen);
|
||||
if (isOpen) onClose?.();
|
||||
};
|
||||
|
||||
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
toggleDropdown();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isOpen) return;
|
||||
setIsOpen(false);
|
||||
onClose?.();
|
||||
setQuery?.("");
|
||||
};
|
||||
|
||||
// options
|
||||
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());
|
||||
});
|
||||
|
||||
if (disableSorting) return filteredOptions;
|
||||
|
||||
return sortBy(filteredOptions, [
|
||||
(option) => firstItem && firstItem(option.data[option.value]),
|
||||
(option) => !(value ?? []).includes(option.data[option.value]),
|
||||
() => sortByKey && sortByKey.toLowerCase(),
|
||||
]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query, options]);
|
||||
|
||||
// hooks
|
||||
const handleKeyDown = useDropdownKeyPressed(toggleDropdown, handleClose);
|
||||
|
||||
useOutsideClickDetector(dropdownRef, handleClose);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
as="div"
|
||||
ref={dropdownRef}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className={cn("h-full", containerClassName)}
|
||||
tabIndex={tabIndex}
|
||||
multiple
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
>
|
||||
<DropdownButton
|
||||
value={value}
|
||||
isOpen={isOpen}
|
||||
setReferenceElement={setReferenceElement}
|
||||
handleOnClick={handleOnClick}
|
||||
buttonContent={buttonContent}
|
||||
buttonClassName={buttonClassName}
|
||||
buttonContainerClassName={buttonContainerClassName}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{isOpen && (
|
||||
<Combobox.Options className="fixed z-10" static>
|
||||
<div
|
||||
className={cn(
|
||||
"my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none",
|
||||
optionsContainerClassName
|
||||
)}
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<DropdownOptions
|
||||
isOpen={isOpen}
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
inputIcon={inputIcon}
|
||||
inputPlaceholder={inputPlaceholder}
|
||||
inputClassName={inputClassName}
|
||||
inputContainerClassName={inputContainerClassName}
|
||||
disableSearch={disableSearch}
|
||||
keyExtractor={keyExtractor}
|
||||
options={sortedOptions}
|
||||
value={value}
|
||||
renderItem={renderItem}
|
||||
loader={loader}
|
||||
/>
|
||||
</div>
|
||||
</Combobox.Options>
|
||||
)}
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
import React, { FC, useMemo, useRef, useState } from "react";
|
||||
import sortBy from "lodash/sortBy";
|
||||
// headless ui
|
||||
import { Combobox } from "@headlessui/react";
|
||||
// popper-js
|
||||
import { usePopper } from "react-popper";
|
||||
// components
|
||||
import { DropdownButton } from "./common";
|
||||
import { DropdownOptions } from "./common/options";
|
||||
// hooks
|
||||
import { useDropdownKeyPressed } from "../hooks/use-dropdown-key-pressed";
|
||||
import useOutsideClickDetector from "../hooks/use-outside-click-detector";
|
||||
// helper
|
||||
import { cn } from "../../helpers";
|
||||
// types
|
||||
import { ISingleSelectDropdown } from "./dropdown";
|
||||
|
||||
export const Dropdown: FC<ISingleSelectDropdown> = (props) => {
|
||||
const {
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
onOpen,
|
||||
onClose,
|
||||
containerClassName,
|
||||
tabIndex,
|
||||
placement,
|
||||
disabled,
|
||||
buttonContent,
|
||||
buttonContainerClassName,
|
||||
buttonClassName,
|
||||
disableSearch,
|
||||
inputPlaceholder,
|
||||
inputClassName,
|
||||
inputIcon,
|
||||
inputContainerClassName,
|
||||
keyExtractor,
|
||||
optionsContainerClassName,
|
||||
queryArray,
|
||||
sortByKey,
|
||||
firstItem,
|
||||
renderItem,
|
||||
loader = false,
|
||||
disableSorting,
|
||||
} = props;
|
||||
|
||||
// states
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
// refs
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
// popper-js refs
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
|
||||
// popper-js init
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: placement ?? "bottom-start",
|
||||
modifiers: [
|
||||
{
|
||||
name: "preventOverflow",
|
||||
options: {
|
||||
padding: 12,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// handlers
|
||||
const toggleDropdown = () => {
|
||||
if (!isOpen) onOpen?.();
|
||||
setIsOpen((prevIsOpen) => !prevIsOpen);
|
||||
if (isOpen) onClose?.();
|
||||
};
|
||||
|
||||
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
toggleDropdown();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isOpen) return;
|
||||
setIsOpen(false);
|
||||
onClose?.();
|
||||
setQuery?.("");
|
||||
};
|
||||
|
||||
// options
|
||||
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());
|
||||
});
|
||||
|
||||
if (disableSorting) return filteredOptions;
|
||||
|
||||
return sortBy(filteredOptions, [
|
||||
(option) => firstItem && firstItem(option.data[option.value]),
|
||||
(option) => !(value ?? []).includes(option.data[option.value]),
|
||||
() => sortByKey && sortByKey.toLowerCase(),
|
||||
]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query, options]);
|
||||
|
||||
// hooks
|
||||
const handleKeyDown = useDropdownKeyPressed(toggleDropdown, handleClose);
|
||||
|
||||
useOutsideClickDetector(dropdownRef, handleClose);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
as="div"
|
||||
ref={dropdownRef}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className={cn("h-full", containerClassName)}
|
||||
tabIndex={tabIndex}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
>
|
||||
<DropdownButton
|
||||
value={value}
|
||||
isOpen={isOpen}
|
||||
setReferenceElement={setReferenceElement}
|
||||
handleOnClick={handleOnClick}
|
||||
buttonContent={buttonContent}
|
||||
buttonClassName={buttonClassName}
|
||||
buttonContainerClassName={buttonContainerClassName}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{isOpen && (
|
||||
<Combobox.Options className="fixed z-10" static>
|
||||
<div
|
||||
className={cn(
|
||||
"my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none",
|
||||
optionsContainerClassName
|
||||
)}
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<DropdownOptions
|
||||
isOpen={isOpen}
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
inputIcon={inputIcon}
|
||||
inputPlaceholder={inputPlaceholder}
|
||||
inputClassName={inputClassName}
|
||||
inputContainerClassName={inputContainerClassName}
|
||||
disableSearch={disableSearch}
|
||||
keyExtractor={keyExtractor}
|
||||
options={sortedOptions}
|
||||
value={value}
|
||||
renderItem={renderItem}
|
||||
loader={loader}
|
||||
/>
|
||||
</div>
|
||||
</Combobox.Options>
|
||||
)}
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
type TUseDropdownKeyPressed = {
|
||||
(
|
||||
onEnterKeyDown: () => void,
|
||||
onEscKeyDown: () => void,
|
||||
stopPropagation?: boolean
|
||||
): (event: React.KeyboardEvent<HTMLElement>) => void;
|
||||
};
|
||||
|
||||
export const useDropdownKeyPressed: TUseDropdownKeyPressed = (onEnterKeyDown, onEscKeyDown, stopPropagation = true) => {
|
||||
const stopEventPropagation = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (stopPropagation) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
[stopPropagation]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
stopEventPropagation(event);
|
||||
onEnterKeyDown();
|
||||
} else if (event.key === "Escape") {
|
||||
stopEventPropagation(event);
|
||||
onEscKeyDown();
|
||||
} else if (event.key === "Tab") onEscKeyDown();
|
||||
},
|
||||
[onEnterKeyDown, onEscKeyDown, stopEventPropagation]
|
||||
);
|
||||
|
||||
return handleKeyDown;
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -4,6 +4,7 @@ export * from "./badge";
|
||||
export * from "./button";
|
||||
export * from "./emoji";
|
||||
export * from "./dropdowns";
|
||||
export * from "./dropdown";
|
||||
export * from "./form-fields";
|
||||
export * from "./icons";
|
||||
export * from "./progress";
|
||||
@@ -13,4 +14,5 @@ export * from "./loader";
|
||||
export * from "./control-link";
|
||||
export * from "./toast";
|
||||
export * from "./drag-handle";
|
||||
export * from "./drop-indicator";
|
||||
export * from "./drop-indicator";
|
||||
export * from "./sortable";
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { isEqual } from "lodash";
|
||||
import { cn } from "../../helpers";
|
||||
import { attachClosestEdge, extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
|
||||
import { DropIndicator } from "../drop-indicator";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
data: any; //@todo make this generic
|
||||
className?: string;
|
||||
};
|
||||
const Draggable = ({ children, data, className }: Props) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [dragging, setDragging] = useState<boolean>(false); // NEW
|
||||
const [isDraggedOver, setIsDraggedOver] = useState(false);
|
||||
|
||||
const [closestEdge, setClosestEdge] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
|
||||
if (el) {
|
||||
combine(
|
||||
draggable({
|
||||
element: el,
|
||||
onDragStart: () => setDragging(true), // NEW
|
||||
onDrop: () => setDragging(false), // NEW
|
||||
getInitialData: () => data,
|
||||
}),
|
||||
dropTargetForElements({
|
||||
element: el,
|
||||
onDragEnter: (args) => {
|
||||
setIsDraggedOver(true);
|
||||
setClosestEdge(extractClosestEdge(args.self.data));
|
||||
},
|
||||
onDragLeave: () => setIsDraggedOver(false),
|
||||
onDrop: () => {
|
||||
setIsDraggedOver(false);
|
||||
},
|
||||
canDrop: ({ source }) => !isEqual(source.data, data) && source.data.__uuid__ === data.__uuid__,
|
||||
getData: ({ input, element }) =>
|
||||
attachClosestEdge(data, {
|
||||
input,
|
||||
element,
|
||||
allowedEdges: ["top", "bottom"],
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn(dragging && "opacity-25", className)}>
|
||||
{<DropIndicator isVisible={isDraggedOver && closestEdge === "top"} />}
|
||||
{children}
|
||||
{<DropIndicator isVisible={isDraggedOver && closestEdge === "bottom"} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { Draggable };
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./sortable";
|
||||
export * from "./draggable";
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import React from "react";
|
||||
import { Draggable } from "./draggable";
|
||||
import { Sortable } from "./sortable";
|
||||
|
||||
const meta: Meta<typeof Sortable> = {
|
||||
title: "Sortable",
|
||||
component: Sortable,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Sortable>;
|
||||
|
||||
const data = [
|
||||
{ id: "1", name: "John Doe" },
|
||||
{ id: "2", name: "Jane Doe 2" },
|
||||
{ id: "3", name: "Alice" },
|
||||
{ id: "4", name: "Bob" },
|
||||
{ id: "5", name: "Charlie" },
|
||||
];
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
data,
|
||||
render: (item: any) => (
|
||||
// <Draggable data={item} className="rounded-lg">
|
||||
<div className="border ">{item.name}</div>
|
||||
// </Draggable>
|
||||
),
|
||||
onChange: (data) => console.log(data.map(({ id }) => id)),
|
||||
keyExtractor: (item: any) => item.id,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import React, { Fragment, useEffect, useMemo } from "react";
|
||||
import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { Draggable } from "./draggable";
|
||||
|
||||
type Props<T> = {
|
||||
data: T[];
|
||||
render: (item: T, index: number) => React.ReactNode;
|
||||
onChange: (data: T[]) => void;
|
||||
keyExtractor: (item: T, index: number) => string;
|
||||
containerClassName?: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
const moveItem = <T,>(
|
||||
data: T[],
|
||||
source: T,
|
||||
destination: T & Record<symbol, string>,
|
||||
keyExtractor: (item: T, index: number) => string
|
||||
) => {
|
||||
const sourceIndex = data.indexOf(source);
|
||||
if (sourceIndex === -1) return data;
|
||||
|
||||
const destinationIndex = data.findIndex((item, index) => keyExtractor(item, index) === keyExtractor(destination, 0));
|
||||
|
||||
if (destinationIndex === -1) return data;
|
||||
|
||||
const symbolKey = Reflect.ownKeys(destination).find((key) => key.toString() === "Symbol(closestEdge)");
|
||||
const position = symbolKey ? destination[symbolKey as symbol] : "bottom"; // Add 'as symbol' to cast symbolKey to symbol
|
||||
const newData = [...data];
|
||||
const [movedItem] = newData.splice(sourceIndex, 1);
|
||||
|
||||
let adjustedDestinationIndex = destinationIndex;
|
||||
if (position === "bottom") {
|
||||
adjustedDestinationIndex++;
|
||||
}
|
||||
|
||||
// Prevent moving item out of bounds
|
||||
if (adjustedDestinationIndex > newData.length) {
|
||||
adjustedDestinationIndex = newData.length;
|
||||
}
|
||||
|
||||
newData.splice(adjustedDestinationIndex, 0, movedItem);
|
||||
|
||||
return newData;
|
||||
};
|
||||
|
||||
export const Sortable = <T,>({ data, render, onChange, keyExtractor, containerClassName, id }: Props<T>) => {
|
||||
useEffect(() => {
|
||||
const unsubscribe = monitorForElements({
|
||||
onDrop({ source, location }) {
|
||||
const destination = location?.current?.dropTargets[0];
|
||||
if (!destination) return;
|
||||
onChange(moveItem(data, source.data as T, destination.data as T & { closestEdge: string }, keyExtractor));
|
||||
},
|
||||
});
|
||||
|
||||
// Clean up the subscription on unmount
|
||||
return () => {
|
||||
if (unsubscribe) unsubscribe();
|
||||
};
|
||||
}, [data, keyExtractor, onChange]);
|
||||
|
||||
const enhancedData = useMemo(() => {
|
||||
const uuid = id ? id : Math.random().toString(36).substring(7);
|
||||
return data.map((item) => ({ ...item, __uuid__: uuid }));
|
||||
}, [data, id]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{enhancedData.map((item, index) => (
|
||||
<Draggable key={keyExtractor(item, index)} data={item} className={containerClassName}>
|
||||
<Fragment>{render(item, index)} </Fragment>
|
||||
</Draggable>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sortable;
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./sub-heading";
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from "react";
|
||||
import { cn } from "../../helpers";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
noMargin?: boolean;
|
||||
};
|
||||
const SubHeading = ({ children, className, noMargin }: Props) => (
|
||||
<h3 className={cn("text-xl font-medium text-custom-text-200 block leading-7", !noMargin && "mb-2", className)}>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
|
||||
export { SubHeading };
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -52,10 +52,13 @@ export enum EAuthenticationErrorCodes {
|
||||
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",
|
||||
@@ -220,6 +223,10 @@ const errorCodeMessages: {
|
||||
},
|
||||
|
||||
// Oauth
|
||||
[EAuthenticationErrorCodes.OAUTH_NOT_CONFIGURED]: {
|
||||
title: `OAuth not configured`,
|
||||
message: () => `OAuth not configured. Please contact your administrator.`,
|
||||
},
|
||||
[EAuthenticationErrorCodes.GOOGLE_NOT_CONFIGURED]: {
|
||||
title: `Google not configured`,
|
||||
message: () => `Google not configured. Please contact your administrator.`,
|
||||
@@ -228,6 +235,10 @@ const errorCodeMessages: {
|
||||
title: `GitHub not configured`,
|
||||
message: () => `GitHub not configured. Please contact your administrator.`,
|
||||
},
|
||||
[EAuthenticationErrorCodes.GITLAB_NOT_CONFIGURED]: {
|
||||
title: `GitLab not configured`,
|
||||
message: () => `GitLab not configured. Please contact your administrator.`,
|
||||
},
|
||||
[EAuthenticationErrorCodes.GOOGLE_OAUTH_PROVIDER_ERROR]: {
|
||||
title: `Google OAuth provider error`,
|
||||
message: () => `Google OAuth provider error. Please try again.`,
|
||||
@@ -236,6 +247,10 @@ const errorCodeMessages: {
|
||||
title: `GitHub OAuth provider error`,
|
||||
message: () => `GitHub OAuth provider error. Please try again.`,
|
||||
},
|
||||
[EAuthenticationErrorCodes.GITLAB_OAUTH_PROVIDER_ERROR]: {
|
||||
title: `GitLab OAuth provider error`,
|
||||
message: () => `GitLab OAuth provider error. Please try again.`,
|
||||
},
|
||||
|
||||
// Reset Password
|
||||
[EAuthenticationErrorCodes.INVALID_PASSWORD_TOKEN]: {
|
||||
@@ -347,10 +362,13 @@ export const authErrorHandler = (
|
||||
EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE_SIGN_UP,
|
||||
EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN,
|
||||
EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP,
|
||||
EAuthenticationErrorCodes.OAUTH_NOT_CONFIGURED,
|
||||
EAuthenticationErrorCodes.GOOGLE_NOT_CONFIGURED,
|
||||
EAuthenticationErrorCodes.GITHUB_NOT_CONFIGURED,
|
||||
EAuthenticationErrorCodes.GITLAB_NOT_CONFIGURED,
|
||||
EAuthenticationErrorCodes.GOOGLE_OAUTH_PROVIDER_ERROR,
|
||||
EAuthenticationErrorCodes.GITHUB_OAUTH_PROVIDER_ERROR,
|
||||
EAuthenticationErrorCodes.GITLAB_OAUTH_PROVIDER_ERROR,
|
||||
EAuthenticationErrorCodes.INVALID_PASSWORD_TOKEN,
|
||||
EAuthenticationErrorCodes.EXPIRED_PASSWORD_TOKEN,
|
||||
EAuthenticationErrorCodes.INCORRECT_OLD_PASSWORD,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "space",
|
||||
"version": "0.20.0",
|
||||
"version": "0.21.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo run develop",
|
||||
|
||||
@@ -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 |
Vendored
+1
@@ -4,6 +4,7 @@ export interface IAppConfig {
|
||||
google_client_id: string | null;
|
||||
github_app_name: string | null;
|
||||
github_client_id: string | null;
|
||||
gitlab_client_id: string | null;
|
||||
magic_login: boolean;
|
||||
slack_client_id: string | null;
|
||||
posthog_api_key: string | null;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { FC } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
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 { query } = useRouter();
|
||||
const { next_path } = query;
|
||||
const { text } = props;
|
||||
// hooks
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const handleSignIn = () => {
|
||||
window.location.assign(`${API_BASE_URL}/auth/gitlab/${next_path ? `?next_path=${next_path}` : ``}`);
|
||||
};
|
||||
|
||||
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 bg-onboarding-background-200 hover:bg-onboarding-background-300 ${
|
||||
resolvedTheme === "dark" ? "border-[#43484F]" : "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";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { GithubOAuthButton, GoogleOAuthButton } from "@/components/account";
|
||||
import { GithubOAuthButton, GitlabOAuthButton, GoogleOAuthButton } from "@/components/account";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
@@ -12,7 +12,7 @@ export const OAuthOptions: React.FC<TOAuthOptionProps> = observer(() => {
|
||||
// hooks
|
||||
const { config } = useInstance();
|
||||
|
||||
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;
|
||||
|
||||
if (!isOAuthEnabled) return null;
|
||||
|
||||
@@ -30,6 +30,7 @@ export const OAuthOptions: React.FC<TOAuthOptionProps> = observer(() => {
|
||||
</div>
|
||||
)}
|
||||
{config?.is_github_enabled && <GithubOAuthButton text="Continue with Github" />}
|
||||
{config?.is_gitlab_enabled && <GitlabOAuthButton text="Continue with GitLab" />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -210,11 +210,7 @@ export const GptAssistantPopover: React.FC<Props> = (props) => {
|
||||
{response !== "" && (
|
||||
<div className="page-block-section max-h-[8rem] text-sm">
|
||||
Response:
|
||||
<RichTextReadOnlyEditor
|
||||
initialValue={`<p>${response}</p>`}
|
||||
containerClassName={response ? "-mx-3 -my-3" : ""}
|
||||
ref={responseRef}
|
||||
/>
|
||||
<RichTextReadOnlyEditor initialValue={`<p>${response}</p>`} ref={responseRef} />
|
||||
</div>
|
||||
)}
|
||||
{invalidResponse && (
|
||||
|
||||
@@ -18,6 +18,8 @@ export const MultipleSelectEntityAction: React.FC<Props> = (props) => {
|
||||
// derived values
|
||||
const isSelected = selectionHelpers.getIsEntitySelected(id);
|
||||
|
||||
if (selectionHelpers.isSelectionDisabled) return null;
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
className={cn("!outline-none size-3.5", className)}
|
||||
|
||||
@@ -17,6 +17,8 @@ export const MultipleSelectGroupAction: React.FC<Props> = (props) => {
|
||||
// derived values
|
||||
const groupSelectionStatus = selectionHelpers.isGroupSelected(groupID);
|
||||
|
||||
if (selectionHelpers.isSelectionDisabled) return null;
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
className={cn("size-3.5 !outline-none", className)}
|
||||
|
||||
@@ -5,14 +5,16 @@ import { TSelectionHelper, useMultipleSelect } from "@/hooks/use-multiple-select
|
||||
type Props = {
|
||||
children: (helpers: TSelectionHelper) => React.ReactNode;
|
||||
containerRef: React.MutableRefObject<HTMLElement | null>;
|
||||
disabled?: boolean;
|
||||
entities: Record<string, string[]>; // { groupID: entityIds[] }
|
||||
};
|
||||
|
||||
export const MultipleSelectGroup: React.FC<Props> = observer((props) => {
|
||||
const { children, containerRef, entities } = props;
|
||||
const { children, containerRef, disabled = false, entities } = props;
|
||||
|
||||
const helpers = useMultipleSelect({
|
||||
containerRef,
|
||||
disabled,
|
||||
entities,
|
||||
});
|
||||
|
||||
|
||||
@@ -156,6 +156,8 @@ export const CycleMobileHeader = () => {
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||
}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
labels={projectLabels}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
states={projectStates}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
// components
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store";
|
||||
// types
|
||||
import { TSelectionHelper } from "@/hooks/use-multiple-select";
|
||||
// constants
|
||||
import { BLOCK_HEIGHT } from "../constants";
|
||||
// components
|
||||
import { ChartAddBlock, ChartDraggable } from "../helpers";
|
||||
import { useGanttChart } from "../hooks";
|
||||
// types
|
||||
import { IBlockUpdateData, IGanttBlock } from "../types";
|
||||
|
||||
type Props = {
|
||||
@@ -21,6 +22,7 @@ type Props = {
|
||||
enableBlockMove: boolean;
|
||||
enableAddBlock: boolean;
|
||||
ganttContainerRef: React.RefObject<HTMLDivElement>;
|
||||
selectionHelpers: TSelectionHelper;
|
||||
};
|
||||
|
||||
export const GanttChartBlock: React.FC<Props> = observer((props) => {
|
||||
@@ -33,6 +35,7 @@ export const GanttChartBlock: React.FC<Props> = observer((props) => {
|
||||
enableBlockMove,
|
||||
enableAddBlock,
|
||||
ganttContainerRef,
|
||||
selectionHelpers,
|
||||
} = props;
|
||||
// store hooks
|
||||
const { updateActiveBlockId, isBlockActive } = useGanttChart();
|
||||
@@ -70,6 +73,10 @@ export const GanttChartBlock: React.FC<Props> = observer((props) => {
|
||||
});
|
||||
};
|
||||
|
||||
const isBlockSelected = selectionHelpers.getIsEntitySelected(block.id);
|
||||
const isBlockFocused = selectionHelpers.getIsEntityActive(block.id);
|
||||
const isBlockHoveredOn = isBlockActive(block.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`block-${block.id}`}
|
||||
@@ -80,10 +87,11 @@ export const GanttChartBlock: React.FC<Props> = observer((props) => {
|
||||
>
|
||||
<div
|
||||
className={cn("relative h-full", {
|
||||
"bg-custom-background-80": isBlockActive(block.id),
|
||||
"rounded-l border border-r-0 border-custom-primary-70 hover:border-custom-primary-70": getIsIssuePeeked(
|
||||
block.data.id
|
||||
),
|
||||
"rounded-l border border-r-0 border-custom-primary-70": getIsIssuePeeked(block.data.id),
|
||||
"bg-custom-background-90": isBlockHoveredOn,
|
||||
"bg-custom-primary-100/5 hover:bg-custom-primary-100/10": isBlockSelected,
|
||||
"bg-custom-primary-100/10": isBlockSelected && isBlockHoveredOn,
|
||||
"border border-r-0 border-custom-border-400": isBlockFocused,
|
||||
})}
|
||||
onMouseEnter={() => updateActiveBlockId(block.id)}
|
||||
onMouseLeave={() => updateActiveBlockId(null)}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { FC } from "react";
|
||||
// components
|
||||
import { HEADER_HEIGHT } from "../constants";
|
||||
import { IBlockUpdateData, IGanttBlock } from "../types";
|
||||
import { GanttChartBlock } from "./block";
|
||||
// types
|
||||
// hooks
|
||||
import { TSelectionHelper } from "@/hooks/use-multiple-select";
|
||||
// constants
|
||||
import { HEADER_HEIGHT } from "../constants";
|
||||
// types
|
||||
import { IBlockUpdateData, IGanttBlock } from "../types";
|
||||
// components
|
||||
import { GanttChartBlock } from "./block";
|
||||
|
||||
export type GanttChartBlocksProps = {
|
||||
itemsContainerWidth: number;
|
||||
@@ -17,6 +19,7 @@ export type GanttChartBlocksProps = {
|
||||
enableAddBlock: boolean;
|
||||
ganttContainerRef: React.RefObject<HTMLDivElement>;
|
||||
showAllBlocks: boolean;
|
||||
selectionHelpers: TSelectionHelper;
|
||||
};
|
||||
|
||||
export const GanttChartBlocksList: FC<GanttChartBlocksProps> = (props) => {
|
||||
@@ -31,6 +34,7 @@ export const GanttChartBlocksList: FC<GanttChartBlocksProps> = (props) => {
|
||||
enableAddBlock,
|
||||
ganttContainerRef,
|
||||
showAllBlocks,
|
||||
selectionHelpers,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
@@ -56,6 +60,7 @@ export const GanttChartBlocksList: FC<GanttChartBlocksProps> = (props) => {
|
||||
enableBlockMove={enableBlockMove}
|
||||
enableAddBlock={enableAddBlock}
|
||||
ganttContainerRef={ganttContainerRef}
|
||||
selectionHelpers={selectionHelpers}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useEffect, useRef } from "react";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
// components
|
||||
import { MultipleSelectGroup } from "@/components/core";
|
||||
import {
|
||||
BiWeekChartView,
|
||||
DayChartView,
|
||||
@@ -18,8 +18,12 @@ import {
|
||||
WeekChartView,
|
||||
YearChartView,
|
||||
} from "@/components/gantt-chart";
|
||||
import { IssueBulkOperationsRoot } from "@/components/issues";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// constants
|
||||
import { GANTT_SELECT_GROUP } from "../constants";
|
||||
// hooks
|
||||
import { useGanttChart } from "../hooks/use-gantt-chart";
|
||||
|
||||
type Props = {
|
||||
@@ -33,6 +37,7 @@ type Props = {
|
||||
enableBlockRightResize: boolean;
|
||||
enableReorder: boolean;
|
||||
enableAddBlock: boolean;
|
||||
enableSelection: boolean;
|
||||
itemsContainerWidth: number;
|
||||
showAllBlocks: boolean;
|
||||
sidebarToRender: (props: any) => React.ReactNode;
|
||||
@@ -53,6 +58,7 @@ export const GanttChartMainContent: React.FC<Props> = observer((props) => {
|
||||
enableBlockRightResize,
|
||||
enableReorder,
|
||||
enableAddBlock,
|
||||
enableSelection,
|
||||
itemsContainerWidth,
|
||||
showAllBlocks,
|
||||
sidebarToRender,
|
||||
@@ -107,43 +113,59 @@ export const GanttChartMainContent: React.FC<Props> = observer((props) => {
|
||||
const ActiveChartView = CHART_VIEW_COMPONENTS[currentView];
|
||||
|
||||
return (
|
||||
<div
|
||||
// DO NOT REMOVE THE ID
|
||||
id="gantt-container"
|
||||
className={cn(
|
||||
"h-full w-full overflow-auto vertical-scrollbar horizontal-scrollbar scrollbar-lg flex border-t-[0.5px] border-custom-border-200",
|
||||
{
|
||||
"mb-8": bottomSpacing,
|
||||
}
|
||||
)}
|
||||
ref={ganttContainerRef}
|
||||
onScroll={onScroll}
|
||||
<MultipleSelectGroup
|
||||
containerRef={ganttContainerRef}
|
||||
entities={{
|
||||
[GANTT_SELECT_GROUP]: chartBlocks?.map((block) => block.id) ?? [],
|
||||
}}
|
||||
disabled
|
||||
>
|
||||
<GanttChartSidebar
|
||||
blocks={blocks}
|
||||
blockUpdateHandler={blockUpdateHandler}
|
||||
enableReorder={enableReorder}
|
||||
sidebarToRender={sidebarToRender}
|
||||
title={title}
|
||||
quickAdd={quickAdd}
|
||||
/>
|
||||
<div className="relative min-h-full h-max flex-shrink-0 flex-grow">
|
||||
<ActiveChartView />
|
||||
{currentViewData && (
|
||||
<GanttChartBlocksList
|
||||
itemsContainerWidth={itemsContainerWidth}
|
||||
blocks={chartBlocks}
|
||||
blockToRender={blockToRender}
|
||||
blockUpdateHandler={blockUpdateHandler}
|
||||
enableBlockLeftResize={enableBlockLeftResize}
|
||||
enableBlockRightResize={enableBlockRightResize}
|
||||
enableBlockMove={enableBlockMove}
|
||||
enableAddBlock={enableAddBlock}
|
||||
ganttContainerRef={ganttContainerRef}
|
||||
showAllBlocks={showAllBlocks}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(helpers) => (
|
||||
<>
|
||||
<div
|
||||
// DO NOT REMOVE THE ID
|
||||
id="gantt-container"
|
||||
className={cn(
|
||||
"h-full w-full overflow-auto vertical-scrollbar horizontal-scrollbar scrollbar-lg flex border-t-[0.5px] border-custom-border-200",
|
||||
{
|
||||
"mb-8": bottomSpacing,
|
||||
}
|
||||
)}
|
||||
ref={ganttContainerRef}
|
||||
onScroll={onScroll}
|
||||
>
|
||||
<GanttChartSidebar
|
||||
blocks={blocks}
|
||||
blockUpdateHandler={blockUpdateHandler}
|
||||
enableReorder={enableReorder}
|
||||
enableSelection={enableSelection}
|
||||
sidebarToRender={sidebarToRender}
|
||||
title={title}
|
||||
quickAdd={quickAdd}
|
||||
selectionHelpers={helpers}
|
||||
/>
|
||||
<div className="relative min-h-full h-max flex-shrink-0 flex-grow">
|
||||
<ActiveChartView />
|
||||
{currentViewData && (
|
||||
<GanttChartBlocksList
|
||||
itemsContainerWidth={itemsContainerWidth}
|
||||
blocks={chartBlocks}
|
||||
blockToRender={blockToRender}
|
||||
blockUpdateHandler={blockUpdateHandler}
|
||||
enableBlockLeftResize={enableBlockLeftResize}
|
||||
enableBlockRightResize={enableBlockRightResize}
|
||||
enableBlockMove={enableBlockMove}
|
||||
enableAddBlock={enableAddBlock}
|
||||
ganttContainerRef={ganttContainerRef}
|
||||
showAllBlocks={showAllBlocks}
|
||||
selectionHelpers={helpers}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<IssueBulkOperationsRoot selectionHelpers={helpers} />
|
||||
</>
|
||||
)}
|
||||
</MultipleSelectGroup>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ type ChartViewRootProps = {
|
||||
enableBlockMove: boolean;
|
||||
enableReorder: boolean;
|
||||
enableAddBlock: boolean;
|
||||
enableSelection: boolean;
|
||||
bottomSpacing: boolean;
|
||||
showAllBlocks: boolean;
|
||||
quickAdd?: React.JSX.Element | undefined;
|
||||
@@ -51,6 +52,7 @@ export const ChartViewRoot: FC<ChartViewRootProps> = observer((props) => {
|
||||
enableBlockMove,
|
||||
enableReorder,
|
||||
enableAddBlock,
|
||||
enableSelection,
|
||||
bottomSpacing,
|
||||
showAllBlocks,
|
||||
quickAdd,
|
||||
@@ -184,6 +186,7 @@ export const ChartViewRoot: FC<ChartViewRootProps> = observer((props) => {
|
||||
enableBlockRightResize={enableBlockRightResize}
|
||||
enableReorder={enableReorder}
|
||||
enableAddBlock={enableAddBlock}
|
||||
enableSelection={enableSelection}
|
||||
itemsContainerWidth={itemsContainerWidth}
|
||||
showAllBlocks={showAllBlocks}
|
||||
sidebarToRender={sidebarToRender}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user