Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c03858fefe | ||
|
|
315facc68d | ||
|
|
0ee6b78564 |
@@ -54,7 +54,7 @@ Getting started with Plane is simple. Choose the setup that works best for you:
|
||||
|
||||
## 🌟 Features
|
||||
|
||||
- **Work Items**
|
||||
- **Issues**
|
||||
Efficiently create and manage tasks with a robust rich text editor that supports file uploads. Enhance organization and tracking by adding sub-properties and referencing related issues.
|
||||
|
||||
- **Cycles**
|
||||
@@ -72,13 +72,15 @@ Getting started with Plane is simple. Choose the setup that works best for you:
|
||||
- **Analytics**
|
||||
Access real-time insights across all your Plane data. Visualize trends, remove blockers, and keep your projects moving forward.
|
||||
|
||||
- **Drive** (_coming soon_): The drive helps you share documents, images, videos, or any other files that make sense to you or your team and align on the problem/solution.
|
||||
|
||||
## 🛠️ Local development
|
||||
|
||||
See [CONTRIBUTING](./CONTRIBUTING.md)
|
||||
|
||||
## ⚙️ Built with
|
||||
|
||||
[](https://reactrouter.com/)
|
||||
[](https://nextjs.org/)
|
||||
[](https://www.djangoproject.com/)
|
||||
[](https://nodejs.org/en)
|
||||
|
||||
|
||||
@@ -114,13 +114,13 @@ export function InstanceAIForm(props: IInstanceAIForm) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 items-start">
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
<Button variant="primary" size="lg" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
|
||||
{isSubmitting ? "Saving" : "Save changes"}
|
||||
{isSubmitting ? "Saving..." : "Save changes"}
|
||||
</Button>
|
||||
|
||||
<div className="relative inline-flex items-center gap-1.5 rounded-sm border border-accent-subtle bg-accent-subtle px-4 py-2 text-caption-sm-regular text-accent-secondary ">
|
||||
<Lightbulb className="size-4" />
|
||||
<div className="relative inline-flex items-center gap-2 rounded-sm border border-accent-strong/20 bg-accent-primary/10 px-4 py-2 text-11 text-accent-secondary">
|
||||
<Lightbulb height="14" width="14" />
|
||||
<div>
|
||||
If you have a preferred AI models vendor, please get in{" "}
|
||||
<a className="underline font-medium" href="https://plane.so/contact">
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import { PageWrapper } from "@/components/common/page-wrapper";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
// types
|
||||
// components
|
||||
import type { Route } from "./+types/page";
|
||||
// local
|
||||
import { InstanceAIForm } from "./form";
|
||||
|
||||
const InstanceAIPage = observer(function InstanceAIPage(_props: Route.ComponentProps) {
|
||||
@@ -17,25 +14,30 @@ const InstanceAIPage = observer(function InstanceAIPage(_props: Route.ComponentP
|
||||
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
header={{
|
||||
title: "AI features for all your workspaces",
|
||||
description: "Configure your AI API credentials so Plane AI features are turned on for all your workspaces.",
|
||||
}}
|
||||
>
|
||||
{formattedConfig ? (
|
||||
<InstanceAIForm config={formattedConfig} />
|
||||
) : (
|
||||
<Loader className="space-y-8">
|
||||
<Loader.Item height="50px" width="40%" />
|
||||
<div className="w-2/3 grid grid-cols-2 gap-x-8 gap-y-4">
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
<>
|
||||
<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-subtle mx-4 py-4 space-y-1 flex-shrink-0">
|
||||
<div className="text-18 font-medium text-primary">AI features for all your workspaces</div>
|
||||
<div className="text-13 font-regular text-tertiary">
|
||||
Configure your AI API credentials so Plane AI features are turned on for all your workspaces.
|
||||
</div>
|
||||
<Loader.Item height="50px" width="20%" />
|
||||
</Loader>
|
||||
)}
|
||||
</PageWrapper>
|
||||
</div>
|
||||
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
|
||||
{formattedConfig ? (
|
||||
<InstanceAIForm config={formattedConfig} />
|
||||
) : (
|
||||
<Loader className="space-y-8">
|
||||
<Loader.Item height="50px" width="40%" />
|
||||
<div className="w-2/3 grid grid-cols-2 gap-x-8 gap-y-4">
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
</div>
|
||||
<Loader.Item height="50px" width="20%" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -12,8 +12,6 @@ import { CodeBlock } from "@/components/common/code-block";
|
||||
import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal";
|
||||
import type { TControllerInputFormField } from "@/components/common/controller-input";
|
||||
import { ControllerInput } from "@/components/common/controller-input";
|
||||
import type { TControllerSwitchFormField } from "@/components/common/controller-switch";
|
||||
import { ControllerSwitch } from "@/components/common/controller-switch";
|
||||
import type { TCopyField } from "@/components/common/copy-field";
|
||||
import { CopyField } from "@/components/common/copy-field";
|
||||
// hooks
|
||||
@@ -42,7 +40,6 @@ export function InstanceGiteaConfigForm(props: Props) {
|
||||
GITEA_HOST: config["GITEA_HOST"] || "https://gitea.com",
|
||||
GITEA_CLIENT_ID: config["GITEA_CLIENT_ID"],
|
||||
GITEA_CLIENT_SECRET: config["GITEA_CLIENT_SECRET"],
|
||||
ENABLE_GITEA_SYNC: config["ENABLE_GITEA_SYNC"] || "0",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -106,11 +103,6 @@ export function InstanceGiteaConfigForm(props: Props) {
|
||||
},
|
||||
];
|
||||
|
||||
const GITEA_FORM_SWITCH_FIELD: TControllerSwitchFormField<GiteaConfigFormValues> = {
|
||||
name: "ENABLE_GITEA_SYNC",
|
||||
label: "Gitea",
|
||||
};
|
||||
|
||||
const GITEA_SERVICE_FIELD: TCopyField[] = [
|
||||
{
|
||||
key: "Callback_URI",
|
||||
@@ -137,22 +129,20 @@ export function InstanceGiteaConfigForm(props: Props) {
|
||||
const onSubmit = async (formData: GiteaConfigFormValues) => {
|
||||
const payload: Partial<GiteaConfigFormValues> = { ...formData };
|
||||
|
||||
try {
|
||||
const response = await updateInstanceConfigurations(payload);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Done!",
|
||||
message: "Your Gitea authentication is configured. You should test it now.",
|
||||
});
|
||||
reset({
|
||||
GITEA_HOST: response.find((item) => item.key === "GITEA_HOST")?.value,
|
||||
GITEA_CLIENT_ID: response.find((item) => item.key === "GITEA_CLIENT_ID")?.value,
|
||||
GITEA_CLIENT_SECRET: response.find((item) => item.key === "GITEA_CLIENT_SECRET")?.value,
|
||||
ENABLE_GITEA_SYNC: response.find((item) => item.key === "ENABLE_GITEA_SYNC")?.value,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
await updateInstanceConfigurations(payload)
|
||||
.then((response = []) => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Done!",
|
||||
message: "Your Gitea authentication is configured. You should test it now.",
|
||||
});
|
||||
reset({
|
||||
GITEA_HOST: response.find((item) => item.key === "GITEA_HOST")?.value,
|
||||
GITEA_CLIENT_ID: response.find((item) => item.key === "GITEA_CLIENT_ID")?.value,
|
||||
GITEA_CLIENT_SECRET: response.find((item) => item.key === "GITEA_CLIENT_SECRET")?.value,
|
||||
});
|
||||
})
|
||||
.catch((err) => console.error(err));
|
||||
};
|
||||
|
||||
const handleGoBack = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
|
||||
@@ -186,17 +176,16 @@ export function InstanceGiteaConfigForm(props: Props) {
|
||||
required={field.required}
|
||||
/>
|
||||
))}
|
||||
<ControllerSwitch control={control} field={GITEA_FORM_SWITCH_FIELD} />
|
||||
<div className="flex flex-col gap-1 pt-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={(e) => void handleSubmit(onSubmit)(e)}
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
loading={isSubmitting}
|
||||
disabled={!isDirty}
|
||||
>
|
||||
{isSubmitting ? "Saving" : "Save changes"}
|
||||
{isSubmitting ? "Saving..." : "Save changes"}
|
||||
</Button>
|
||||
<Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}>
|
||||
Go back
|
||||
@@ -205,7 +194,7 @@ export function InstanceGiteaConfigForm(props: Props) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
<div className="flex flex-col gap-y-4 px-6 pt-1.5 pb-4 bg-layer-1 rounded-lg">
|
||||
<div className="flex flex-col gap-y-4 px-6 pt-1.5 pb-4 bg-layer-1/60 rounded-lg">
|
||||
<div className="pt-2 text-18 font-medium">Plane-provided details for Gitea</div>
|
||||
{GITEA_SERVICE_FIELD.map((field) => (
|
||||
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
|
||||
|
||||
@@ -4,16 +4,13 @@ import useSWR from "swr";
|
||||
// plane internal packages
|
||||
import { setPromiseToast } from "@plane/propel/toast";
|
||||
import { Loader, ToggleSwitch } from "@plane/ui";
|
||||
// assets
|
||||
import giteaLogo from "@/app/assets/logos/gitea-logo.svg?url";
|
||||
// components
|
||||
import giteaLogo from "@/app/assets/logos/gitea-logo.svg?url";
|
||||
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
|
||||
import { PageWrapper } from "@/components/common/page-wrapper";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
// types
|
||||
//local components
|
||||
import type { Route } from "./+types/page";
|
||||
// local
|
||||
import { InstanceGiteaConfigForm } from "./form";
|
||||
|
||||
const InstanceGiteaAuthenticationPage = observer(function InstanceGiteaAuthenticationPage() {
|
||||
@@ -35,7 +32,7 @@ const InstanceGiteaAuthenticationPage = observer(function InstanceGiteaAuthentic
|
||||
const updateConfigPromise = updateInstanceConfigurations(payload);
|
||||
|
||||
setPromiseToast(updateConfigPromise, {
|
||||
loading: "Saving Configuration",
|
||||
loading: "Saving Configuration...",
|
||||
success: {
|
||||
title: "Configuration saved",
|
||||
message: () => `Gitea authentication is now ${value === "1" ? "active" : "disabled"}.`,
|
||||
@@ -59,39 +56,42 @@ const InstanceGiteaAuthenticationPage = observer(function InstanceGiteaAuthentic
|
||||
const isGiteaEnabled = enableGiteaConfig === "1";
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
customHeader={
|
||||
<AuthenticationMethodCard
|
||||
name="Gitea"
|
||||
description="Allow members to login or sign up to plane with their Gitea accounts."
|
||||
icon={<img src={giteaLogo} height={24} width={24} alt="Gitea Logo" />}
|
||||
config={
|
||||
<ToggleSwitch
|
||||
value={isGiteaEnabled}
|
||||
onChange={() => {
|
||||
updateConfig("IS_GITEA_ENABLED", isGiteaEnabled ? "0" : "1");
|
||||
}}
|
||||
size="sm"
|
||||
disabled={isSubmitting || !formattedConfig}
|
||||
/>
|
||||
}
|
||||
disabled={isSubmitting || !formattedConfig}
|
||||
withBorder={false}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{formattedConfig ? (
|
||||
<InstanceGiteaConfigForm 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>
|
||||
)}
|
||||
</PageWrapper>
|
||||
<>
|
||||
<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-subtle mx-4 py-4 space-y-1 flex-shrink-0">
|
||||
<AuthenticationMethodCard
|
||||
name="Gitea"
|
||||
description="Allow members to login or sign up to plane with their Gitea accounts."
|
||||
icon={<img src={giteaLogo} height={24} width={24} alt="Gitea Logo" />}
|
||||
config={
|
||||
<ToggleSwitch
|
||||
value={isGiteaEnabled}
|
||||
onChange={() => {
|
||||
updateConfig("IS_GITEA_ENABLED", isGiteaEnabled ? "0" : "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 px-4">
|
||||
{formattedConfig ? (
|
||||
<InstanceGiteaConfigForm 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 const meta: Route.MetaFunction = () => [{ title: "Gitea Authentication - God Mode" }];
|
||||
|
||||
@@ -8,12 +8,12 @@ import { API_BASE_URL } from "@plane/constants";
|
||||
import { Button, getButtonStyling } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IFormattedInstanceConfiguration, TInstanceGithubAuthenticationConfigurationKeys } from "@plane/types";
|
||||
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { CodeBlock } from "@/components/common/code-block";
|
||||
import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal";
|
||||
import type { TControllerInputFormField } from "@/components/common/controller-input";
|
||||
import type { TControllerSwitchFormField } from "@/components/common/controller-switch";
|
||||
import { ControllerSwitch } from "@/components/common/controller-switch";
|
||||
import { ControllerInput } from "@/components/common/controller-input";
|
||||
import type { TCopyField } from "@/components/common/copy-field";
|
||||
import { CopyField } from "@/components/common/copy-field";
|
||||
@@ -43,7 +43,6 @@ export function InstanceGithubConfigForm(props: Props) {
|
||||
GITHUB_CLIENT_ID: config["GITHUB_CLIENT_ID"],
|
||||
GITHUB_CLIENT_SECRET: config["GITHUB_CLIENT_SECRET"],
|
||||
GITHUB_ORGANIZATION_ID: config["GITHUB_ORGANIZATION_ID"],
|
||||
ENABLE_GITHUB_SYNC: config["ENABLE_GITHUB_SYNC"] || "0",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -105,11 +104,6 @@ export function InstanceGithubConfigForm(props: Props) {
|
||||
},
|
||||
];
|
||||
|
||||
const GITHUB_FORM_SWITCH_FIELD: TControllerSwitchFormField<GithubConfigFormValues> = {
|
||||
name: "ENABLE_GITHUB_SYNC",
|
||||
label: "GitHub",
|
||||
};
|
||||
|
||||
const GITHUB_COMMON_SERVICE_DETAILS: TCopyField[] = [
|
||||
{
|
||||
key: "Origin_URL",
|
||||
@@ -158,22 +152,20 @@ export function InstanceGithubConfigForm(props: Props) {
|
||||
const onSubmit = async (formData: GithubConfigFormValues) => {
|
||||
const payload: Partial<GithubConfigFormValues> = { ...formData };
|
||||
|
||||
try {
|
||||
const response = await updateInstanceConfigurations(payload);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Done!",
|
||||
message: "Your GitHub authentication is configured. You should test it now.",
|
||||
});
|
||||
reset({
|
||||
GITHUB_CLIENT_ID: response.find((item) => item.key === "GITHUB_CLIENT_ID")?.value,
|
||||
GITHUB_CLIENT_SECRET: response.find((item) => item.key === "GITHUB_CLIENT_SECRET")?.value,
|
||||
GITHUB_ORGANIZATION_ID: response.find((item) => item.key === "GITHUB_ORGANIZATION_ID")?.value,
|
||||
ENABLE_GITHUB_SYNC: response.find((item) => item.key === "ENABLE_GITHUB_SYNC")?.value,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
await updateInstanceConfigurations(payload)
|
||||
.then((response = []) => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Done!",
|
||||
message: "Your GitHub authentication is configured. You should test it now.",
|
||||
});
|
||||
reset({
|
||||
GITHUB_CLIENT_ID: response.find((item) => item.key === "GITHUB_CLIENT_ID")?.value,
|
||||
GITHUB_CLIENT_SECRET: response.find((item) => item.key === "GITHUB_CLIENT_SECRET")?.value,
|
||||
GITHUB_ORGANIZATION_ID: response.find((item) => item.key === "GITHUB_ORGANIZATION_ID")?.value,
|
||||
});
|
||||
})
|
||||
.catch((err) => console.error(err));
|
||||
};
|
||||
|
||||
const handleGoBack = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
|
||||
@@ -207,17 +199,16 @@ export function InstanceGithubConfigForm(props: Props) {
|
||||
required={field.required}
|
||||
/>
|
||||
))}
|
||||
<ControllerSwitch control={control} field={GITHUB_FORM_SWITCH_FIELD} />
|
||||
<div className="flex flex-col gap-1 pt-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={(e) => void handleSubmit(onSubmit)(e)}
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
loading={isSubmitting}
|
||||
disabled={!isDirty}
|
||||
>
|
||||
{isSubmitting ? "Saving" : "Save changes"}
|
||||
{isSubmitting ? "Saving..." : "Save changes"}
|
||||
</Button>
|
||||
<Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}>
|
||||
Go back
|
||||
@@ -238,7 +229,7 @@ export function InstanceGithubConfigForm(props: Props) {
|
||||
|
||||
{/* web service details */}
|
||||
<div className="flex flex-col rounded-lg overflow-hidden">
|
||||
<div className="px-6 py-3 bg-layer-3 font-medium text-11 uppercase flex items-center gap-x-3 text-secondary">
|
||||
<div className="px-6 py-3 bg-layer-1/60 font-medium text-11 uppercase flex items-center gap-x-3 text-secondary">
|
||||
<Monitor className="w-3 h-3" />
|
||||
Web
|
||||
</div>
|
||||
|
||||
@@ -6,17 +6,15 @@ import useSWR from "swr";
|
||||
import { setPromiseToast } from "@plane/propel/toast";
|
||||
import { Loader, ToggleSwitch } from "@plane/ui";
|
||||
import { resolveGeneralTheme } from "@plane/utils";
|
||||
// assets
|
||||
// components
|
||||
import githubLightModeImage from "@/app/assets/logos/github-black.png?url";
|
||||
import githubDarkModeImage from "@/app/assets/logos/github-white.png?url";
|
||||
// components
|
||||
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
|
||||
import { PageWrapper } from "@/components/common/page-wrapper";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
// types
|
||||
// icons
|
||||
// local components
|
||||
import type { Route } from "./+types/page";
|
||||
// local
|
||||
import { InstanceGithubConfigForm } from "./form";
|
||||
|
||||
const InstanceGithubAuthenticationPage = observer(function InstanceGithubAuthenticationPage(
|
||||
@@ -43,7 +41,7 @@ const InstanceGithubAuthenticationPage = observer(function InstanceGithubAuthent
|
||||
const updateConfigPromise = updateInstanceConfigurations(payload);
|
||||
|
||||
setPromiseToast(updateConfigPromise, {
|
||||
loading: "Saving Configuration",
|
||||
loading: "Saving Configuration...",
|
||||
success: {
|
||||
title: "Configuration saved",
|
||||
message: () => `GitHub authentication is now ${value === "1" ? "active" : "disabled"}.`,
|
||||
@@ -67,46 +65,49 @@ const InstanceGithubAuthenticationPage = observer(function InstanceGithubAuthent
|
||||
const isGithubEnabled = enableGithubConfig === "1";
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
customHeader={
|
||||
<AuthenticationMethodCard
|
||||
name="GitHub"
|
||||
description="Allow members to login or sign up to plane with their GitHub accounts."
|
||||
icon={
|
||||
<img
|
||||
src={resolveGeneralTheme(resolvedTheme) === "dark" ? githubDarkModeImage : githubLightModeImage}
|
||||
height={24}
|
||||
width={24}
|
||||
alt="GitHub Logo"
|
||||
/>
|
||||
}
|
||||
config={
|
||||
<ToggleSwitch
|
||||
value={isGithubEnabled}
|
||||
onChange={() => {
|
||||
updateConfig("IS_GITHUB_ENABLED", isGithubEnabled ? "0" : "1");
|
||||
}}
|
||||
size="sm"
|
||||
disabled={isSubmitting || !formattedConfig}
|
||||
/>
|
||||
}
|
||||
disabled={isSubmitting || !formattedConfig}
|
||||
withBorder={false}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{formattedConfig ? (
|
||||
<InstanceGithubConfigForm 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>
|
||||
)}
|
||||
</PageWrapper>
|
||||
<>
|
||||
<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-subtle mx-4 py-4 space-y-1 flex-shrink-0">
|
||||
<AuthenticationMethodCard
|
||||
name="GitHub"
|
||||
description="Allow members to login or sign up to plane with their GitHub accounts."
|
||||
icon={
|
||||
<img
|
||||
src={resolveGeneralTheme(resolvedTheme) === "dark" ? githubDarkModeImage : githubLightModeImage}
|
||||
height={24}
|
||||
width={24}
|
||||
alt="GitHub Logo"
|
||||
/>
|
||||
}
|
||||
config={
|
||||
<ToggleSwitch
|
||||
value={isGithubEnabled}
|
||||
onChange={() => {
|
||||
updateConfig("IS_GITHUB_ENABLED", isGithubEnabled ? "0" : "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 px-4">
|
||||
{formattedConfig ? (
|
||||
<InstanceGithubConfigForm 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -7,12 +7,11 @@ import { API_BASE_URL } from "@plane/constants";
|
||||
import { Button, getButtonStyling } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IFormattedInstanceConfiguration, TInstanceGitlabAuthenticationConfigurationKeys } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { CodeBlock } from "@/components/common/code-block";
|
||||
import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal";
|
||||
import type { TControllerInputFormField } from "@/components/common/controller-input";
|
||||
import type { TControllerSwitchFormField } from "@/components/common/controller-switch";
|
||||
import { ControllerSwitch } from "@/components/common/controller-switch";
|
||||
import { ControllerInput } from "@/components/common/controller-input";
|
||||
import type { TCopyField } from "@/components/common/copy-field";
|
||||
import { CopyField } from "@/components/common/copy-field";
|
||||
@@ -42,7 +41,6 @@ export function InstanceGitlabConfigForm(props: Props) {
|
||||
GITLAB_HOST: config["GITLAB_HOST"],
|
||||
GITLAB_CLIENT_ID: config["GITLAB_CLIENT_ID"],
|
||||
GITLAB_CLIENT_SECRET: config["GITLAB_CLIENT_SECRET"],
|
||||
ENABLE_GITLAB_SYNC: config["ENABLE_GITLAB_SYNC"] || "0",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -110,11 +108,6 @@ export function InstanceGitlabConfigForm(props: Props) {
|
||||
},
|
||||
];
|
||||
|
||||
const GITLAB_FORM_SWITCH_FIELD: TControllerSwitchFormField<GitlabConfigFormValues> = {
|
||||
name: "ENABLE_GITLAB_SYNC",
|
||||
label: "GitLab",
|
||||
};
|
||||
|
||||
const GITLAB_SERVICE_FIELD: TCopyField[] = [
|
||||
{
|
||||
key: "Callback_URL",
|
||||
@@ -141,22 +134,20 @@ export function InstanceGitlabConfigForm(props: Props) {
|
||||
const onSubmit = async (formData: GitlabConfigFormValues) => {
|
||||
const payload: Partial<GitlabConfigFormValues> = { ...formData };
|
||||
|
||||
try {
|
||||
const response = await updateInstanceConfigurations(payload);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Done!",
|
||||
message: "Your GitLab authentication is configured. You should test it now.",
|
||||
});
|
||||
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,
|
||||
ENABLE_GITLAB_SYNC: response.find((item) => item.key === "ENABLE_GITLAB_SYNC")?.value,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
await updateInstanceConfigurations(payload)
|
||||
.then((response = []) => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Done!",
|
||||
message: "Your GitLab authentication is configured. You should test it now.",
|
||||
});
|
||||
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>) => {
|
||||
@@ -190,17 +181,16 @@ export function InstanceGitlabConfigForm(props: Props) {
|
||||
required={field.required}
|
||||
/>
|
||||
))}
|
||||
<ControllerSwitch control={control} field={GITLAB_FORM_SWITCH_FIELD} />
|
||||
<div className="flex flex-col gap-1 pt-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={(e) => void handleSubmit(onSubmit)(e)}
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
loading={isSubmitting}
|
||||
disabled={!isDirty}
|
||||
>
|
||||
{isSubmitting ? "Saving" : "Save changes"}
|
||||
{isSubmitting ? "Saving..." : "Save changes"}
|
||||
</Button>
|
||||
<Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}>
|
||||
Go back
|
||||
@@ -209,7 +199,7 @@ export function InstanceGitlabConfigForm(props: Props) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
<div className="flex flex-col gap-y-4 px-6 pt-1.5 pb-4 bg-layer-3 rounded-lg">
|
||||
<div className="flex flex-col gap-y-4 px-6 pt-1.5 pb-4 bg-layer-1/60 rounded-lg">
|
||||
<div className="pt-2 text-18 font-medium">Plane-provided details for GitLab</div>
|
||||
{GITLAB_SERVICE_FIELD.map((field) => (
|
||||
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
|
||||
|
||||
@@ -3,16 +3,14 @@ import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
import { setPromiseToast } from "@plane/propel/toast";
|
||||
import { Loader, ToggleSwitch } from "@plane/ui";
|
||||
// assets
|
||||
import GitlabLogo from "@/app/assets/logos/gitlab-logo.svg?url";
|
||||
// components
|
||||
import GitlabLogo from "@/app/assets/logos/gitlab-logo.svg?url";
|
||||
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
|
||||
import { PageWrapper } from "@/components/common/page-wrapper";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
// types
|
||||
// icons
|
||||
// local components
|
||||
import type { Route } from "./+types/page";
|
||||
// local
|
||||
import { InstanceGitlabConfigForm } from "./form";
|
||||
|
||||
const InstanceGitlabAuthenticationPage = observer(function InstanceGitlabAuthenticationPage(
|
||||
@@ -37,7 +35,7 @@ const InstanceGitlabAuthenticationPage = observer(function InstanceGitlabAuthent
|
||||
const updateConfigPromise = updateInstanceConfigurations(payload);
|
||||
|
||||
setPromiseToast(updateConfigPromise, {
|
||||
loading: "Saving Configuration",
|
||||
loading: "Saving Configuration...",
|
||||
success: {
|
||||
title: "Configuration saved",
|
||||
message: () => `GitLab authentication is now ${value === "1" ? "active" : "disabled"}.`,
|
||||
@@ -58,43 +56,46 @@ const InstanceGitlabAuthenticationPage = observer(function InstanceGitlabAuthent
|
||||
});
|
||||
};
|
||||
return (
|
||||
<PageWrapper
|
||||
customHeader={
|
||||
<AuthenticationMethodCard
|
||||
name="GitLab"
|
||||
description="Allow members to login or sign up to plane with their GitLab accounts."
|
||||
icon={<img src={GitlabLogo} height={24} width={24} alt="GitLab Logo" />}
|
||||
config={
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableGitlabConfig))}
|
||||
onChange={() => {
|
||||
if (Boolean(parseInt(enableGitlabConfig)) === true) {
|
||||
updateConfig("IS_GITLAB_ENABLED", "0");
|
||||
} else {
|
||||
updateConfig("IS_GITLAB_ENABLED", "1");
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
disabled={isSubmitting || !formattedConfig}
|
||||
/>
|
||||
}
|
||||
disabled={isSubmitting || !formattedConfig}
|
||||
withBorder={false}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
</PageWrapper>
|
||||
<>
|
||||
<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-subtle 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={<img src={GitlabLogo} height={24} width={24} alt="GitLab Logo" />}
|
||||
config={
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableGitlabConfig))}
|
||||
onChange={() => {
|
||||
if (Boolean(parseInt(enableGitlabConfig)) === true) {
|
||||
updateConfig("IS_GITLAB_ENABLED", "0");
|
||||
} else {
|
||||
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 px-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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,12 +8,11 @@ import { API_BASE_URL } from "@plane/constants";
|
||||
import { Button, getButtonStyling } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IFormattedInstanceConfiguration, TInstanceGoogleAuthenticationConfigurationKeys } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { CodeBlock } from "@/components/common/code-block";
|
||||
import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal";
|
||||
import type { TControllerInputFormField } from "@/components/common/controller-input";
|
||||
import type { TControllerSwitchFormField } from "@/components/common/controller-switch";
|
||||
import { ControllerSwitch } from "@/components/common/controller-switch";
|
||||
import { ControllerInput } from "@/components/common/controller-input";
|
||||
import type { TCopyField } from "@/components/common/copy-field";
|
||||
import { CopyField } from "@/components/common/copy-field";
|
||||
@@ -42,7 +41,6 @@ export function InstanceGoogleConfigForm(props: Props) {
|
||||
defaultValues: {
|
||||
GOOGLE_CLIENT_ID: config["GOOGLE_CLIENT_ID"],
|
||||
GOOGLE_CLIENT_SECRET: config["GOOGLE_CLIENT_SECRET"],
|
||||
ENABLE_GOOGLE_SYNC: config["ENABLE_GOOGLE_SYNC"] || "0",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -95,11 +93,6 @@ export function InstanceGoogleConfigForm(props: Props) {
|
||||
},
|
||||
];
|
||||
|
||||
const GOOGLE_FORM_SWITCH_FIELD: TControllerSwitchFormField<GoogleConfigFormValues> = {
|
||||
name: "ENABLE_GOOGLE_SYNC",
|
||||
label: "Google",
|
||||
};
|
||||
|
||||
const GOOGLE_COMMON_SERVICE_DETAILS: TCopyField[] = [
|
||||
{
|
||||
key: "Origin_URL",
|
||||
@@ -147,21 +140,19 @@ export function InstanceGoogleConfigForm(props: Props) {
|
||||
const onSubmit = async (formData: GoogleConfigFormValues) => {
|
||||
const payload: Partial<GoogleConfigFormValues> = { ...formData };
|
||||
|
||||
try {
|
||||
const response = await updateInstanceConfigurations(payload);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Done!",
|
||||
message: "Your Google authentication is configured. You should test it now.",
|
||||
});
|
||||
reset({
|
||||
GOOGLE_CLIENT_ID: response.find((item) => item.key === "GOOGLE_CLIENT_ID")?.value,
|
||||
GOOGLE_CLIENT_SECRET: response.find((item) => item.key === "GOOGLE_CLIENT_SECRET")?.value,
|
||||
ENABLE_GOOGLE_SYNC: response.find((item) => item.key === "ENABLE_GOOGLE_SYNC")?.value,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
await updateInstanceConfigurations(payload)
|
||||
.then((response = []) => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Done!",
|
||||
message: "Your Google authentication is configured. You should test it now.",
|
||||
});
|
||||
reset({
|
||||
GOOGLE_CLIENT_ID: response.find((item) => item.key === "GOOGLE_CLIENT_ID")?.value,
|
||||
GOOGLE_CLIENT_SECRET: response.find((item) => item.key === "GOOGLE_CLIENT_SECRET")?.value,
|
||||
});
|
||||
})
|
||||
.catch((err) => console.error(err));
|
||||
};
|
||||
|
||||
const handleGoBack = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
|
||||
@@ -195,17 +186,16 @@ export function InstanceGoogleConfigForm(props: Props) {
|
||||
required={field.required}
|
||||
/>
|
||||
))}
|
||||
<ControllerSwitch control={control} field={GOOGLE_FORM_SWITCH_FIELD} />
|
||||
<div className="flex flex-col gap-1 pt-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={(e) => void handleSubmit(onSubmit)(e)}
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
loading={isSubmitting}
|
||||
disabled={!isDirty}
|
||||
>
|
||||
{isSubmitting ? "Saving" : "Save changes"}
|
||||
{isSubmitting ? "Saving..." : "Save changes"}
|
||||
</Button>
|
||||
<Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}>
|
||||
Go back
|
||||
@@ -226,7 +216,7 @@ export function InstanceGoogleConfigForm(props: Props) {
|
||||
|
||||
{/* web service details */}
|
||||
<div className="flex flex-col rounded-lg overflow-hidden">
|
||||
<div className="px-6 py-3 bg-layer-3 font-medium text-11 uppercase flex items-center gap-x-3 text-secondary">
|
||||
<div className="px-6 py-3 bg-layer-1/60 font-medium text-11 uppercase flex items-center gap-x-3 text-secondary">
|
||||
<Monitor className="w-3 h-3" />
|
||||
Web
|
||||
</div>
|
||||
|
||||
@@ -3,16 +3,14 @@ import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
import { setPromiseToast } from "@plane/propel/toast";
|
||||
import { Loader, ToggleSwitch } from "@plane/ui";
|
||||
// assets
|
||||
import GoogleLogo from "@/app/assets/logos/google-logo.svg?url";
|
||||
// components
|
||||
import GoogleLogo from "@/app/assets/logos/google-logo.svg?url";
|
||||
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
|
||||
import { PageWrapper } from "@/components/common/page-wrapper";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
// types
|
||||
// icons
|
||||
// local components
|
||||
import type { Route } from "./+types/page";
|
||||
// local
|
||||
import { InstanceGoogleConfigForm } from "./form";
|
||||
|
||||
const InstanceGoogleAuthenticationPage = observer(function InstanceGoogleAuthenticationPage(
|
||||
@@ -37,7 +35,7 @@ const InstanceGoogleAuthenticationPage = observer(function InstanceGoogleAuthent
|
||||
const updateConfigPromise = updateInstanceConfigurations(payload);
|
||||
|
||||
setPromiseToast(updateConfigPromise, {
|
||||
loading: "Saving Configuration",
|
||||
loading: "Saving Configuration...",
|
||||
success: {
|
||||
title: "Configuration saved",
|
||||
message: () => `Google authentication is now ${value === "1" ? "active" : "disabled"}.`,
|
||||
@@ -58,44 +56,47 @@ const InstanceGoogleAuthenticationPage = observer(function InstanceGoogleAuthent
|
||||
});
|
||||
};
|
||||
return (
|
||||
<PageWrapper
|
||||
customHeader={
|
||||
<AuthenticationMethodCard
|
||||
name="Google"
|
||||
description="Allow members to login or sign up to plane with their Google
|
||||
<>
|
||||
<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-subtle mx-4 py-4 space-y-1 flex-shrink-0">
|
||||
<AuthenticationMethodCard
|
||||
name="Google"
|
||||
description="Allow members to login or sign up to plane with their Google
|
||||
accounts."
|
||||
icon={<img src={GoogleLogo} height={24} width={24} alt="Google Logo" />}
|
||||
config={
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableGoogleConfig))}
|
||||
onChange={() => {
|
||||
if (Boolean(parseInt(enableGoogleConfig)) === true) {
|
||||
updateConfig("IS_GOOGLE_ENABLED", "0");
|
||||
} else {
|
||||
updateConfig("IS_GOOGLE_ENABLED", "1");
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
disabled={isSubmitting || !formattedConfig}
|
||||
/>
|
||||
}
|
||||
disabled={isSubmitting || !formattedConfig}
|
||||
withBorder={false}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{formattedConfig ? (
|
||||
<InstanceGoogleConfigForm 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>
|
||||
)}
|
||||
</PageWrapper>
|
||||
icon={<img src={GoogleLogo} height={24} width={24} alt="Google Logo" />}
|
||||
config={
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableGoogleConfig))}
|
||||
onChange={() => {
|
||||
if (Boolean(parseInt(enableGoogleConfig)) === true) {
|
||||
updateConfig("IS_GOOGLE_ENABLED", "0");
|
||||
} else {
|
||||
updateConfig("IS_GOOGLE_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 px-4">
|
||||
{formattedConfig ? (
|
||||
<InstanceGoogleConfigForm 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,33 +1,27 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import useSWR from "swr";
|
||||
// plane internal packages
|
||||
import { setPromiseToast } from "@plane/propel/toast";
|
||||
import type { TInstanceConfigurationKeys } from "@plane/types";
|
||||
import { Loader, ToggleSwitch } from "@plane/ui";
|
||||
import { cn, resolveGeneralTheme } from "@plane/utils";
|
||||
// components
|
||||
import { PageWrapper } from "@/components/common/page-wrapper";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
|
||||
import { useAuthenticationModes } from "@/hooks/oauth";
|
||||
import { useInstance } from "@/hooks/store";
|
||||
// types
|
||||
// plane admin components
|
||||
import { AuthenticationModes } from "@/plane-admin/components/authentication";
|
||||
import type { Route } from "./+types/page";
|
||||
|
||||
const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(_props: Route.ComponentProps) {
|
||||
// theme
|
||||
const { resolvedTheme: resolvedThemeAdmin } = useTheme();
|
||||
// store
|
||||
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
|
||||
|
||||
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
|
||||
|
||||
// state
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
// derived values
|
||||
const enableSignUpConfig = formattedConfig?.ENABLE_SIGNUP ?? "";
|
||||
const resolvedTheme = resolveGeneralTheme(resolvedThemeAdmin);
|
||||
|
||||
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
|
||||
|
||||
const updateConfig = async (key: TInstanceConfigurationKeys, value: string) => {
|
||||
setIsSubmitting(true);
|
||||
@@ -60,65 +54,59 @@ const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(
|
||||
});
|
||||
};
|
||||
|
||||
const authenticationModes = useAuthenticationModes({ disabled: isSubmitting, updateConfig, resolvedTheme });
|
||||
return (
|
||||
<PageWrapper
|
||||
header={{
|
||||
title: "Manage authentication modes for your instance",
|
||||
description: "Configure authentication modes for your team and restrict sign-ups to be invite only.",
|
||||
}}
|
||||
>
|
||||
{formattedConfig ? (
|
||||
<div className="space-y-3">
|
||||
<div className={cn("w-full flex items-center gap-14 rounded-sm")}>
|
||||
<div className="flex grow items-center gap-4">
|
||||
<div className="grow">
|
||||
<div className="text-16 font-medium pb-1">Allow anyone to sign up even without an invite</div>
|
||||
<div className={cn("font-regular leading-5 text-tertiary text-11")}>
|
||||
Toggling this off will only let users sign up when they are invited.
|
||||
<>
|
||||
<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-subtle mx-4 py-4 space-y-1 flex-shrink-0">
|
||||
<div className="text-18 font-medium text-primary">Manage authentication modes for your instance</div>
|
||||
<div className="text-13 font-regular text-tertiary">
|
||||
Configure authentication modes for your team and restrict sign-ups to be invite only.
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
|
||||
{formattedConfig ? (
|
||||
<div className="space-y-3">
|
||||
<div className={cn("w-full flex items-center gap-14 rounded-sm")}>
|
||||
<div className="flex grow items-center gap-4">
|
||||
<div className="grow">
|
||||
<div className="text-16 font-medium pb-1">Allow anyone to sign up even without an invite</div>
|
||||
<div className={cn("font-regular leading-5 text-tertiary text-11")}>
|
||||
Toggling this off will only let users sign up when they are invited.
|
||||
</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={() => {
|
||||
if (Boolean(parseInt(enableSignUpConfig)) === true) {
|
||||
updateConfig("ENABLE_SIGNUP", "0");
|
||||
} else {
|
||||
updateConfig("ENABLE_SIGNUP", "1");
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-16 font-medium pt-6">Available authentication modes</div>
|
||||
<AuthenticationModes disabled={isSubmitting} updateConfig={updateConfig} />
|
||||
</div>
|
||||
<div className={`shrink-0 pr-4 ${isSubmitting && "opacity-70"}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableSignUpConfig))}
|
||||
onChange={() => {
|
||||
if (Boolean(parseInt(enableSignUpConfig)) === true) {
|
||||
updateConfig("ENABLE_SIGNUP", "0");
|
||||
} else {
|
||||
updateConfig("ENABLE_SIGNUP", "1");
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-lg font-medium pt-6">Available authentication modes</div>
|
||||
{authenticationModes.map((method) => (
|
||||
<AuthenticationMethodCard
|
||||
key={method.key}
|
||||
name={method.name}
|
||||
description={method.description}
|
||||
icon={method.icon}
|
||||
config={method.config}
|
||||
disabled={isSubmitting}
|
||||
unavailable={method.unavailable}
|
||||
/>
|
||||
))}
|
||||
) : (
|
||||
<Loader className="space-y-10">
|
||||
<Loader.Item height="50px" width="75%" />
|
||||
<Loader.Item height="50px" width="75%" />
|
||||
<Loader.Item height="50px" width="40%" />
|
||||
<Loader.Item height="50px" width="40%" />
|
||||
<Loader.Item height="50px" width="20%" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Loader className="space-y-10">
|
||||
<Loader.Item height="50px" width="75%" />
|
||||
<Loader.Item height="50px" width="75%" />
|
||||
<Loader.Item height="50px" width="40%" />
|
||||
<Loader.Item height="50px" width="40%" />
|
||||
<Loader.Item height="50px" width="20%" />
|
||||
</Loader>
|
||||
)}
|
||||
</PageWrapper>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -209,7 +209,7 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
|
||||
loading={isSubmitting}
|
||||
disabled={!isValid || !isDirty}
|
||||
>
|
||||
{isSubmitting ? "Saving" : "Save changes"}
|
||||
{isSubmitting ? "Saving..." : "Save changes"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
|
||||
@@ -3,13 +3,10 @@ import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Loader, ToggleSwitch } from "@plane/ui";
|
||||
// components
|
||||
import { PageWrapper } from "@/components/common/page-wrapper";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
// types
|
||||
// components
|
||||
import type { Route } from "./+types/page";
|
||||
// local
|
||||
import { InstanceEmailForm } from "./email-config-form";
|
||||
|
||||
const InstanceEmailPage = observer(function InstanceEmailPage(_props: Route.ComponentProps) {
|
||||
@@ -52,43 +49,44 @@ const InstanceEmailPage = observer(function InstanceEmailPage(_props: Route.Comp
|
||||
}, [formattedConfig]);
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
header={{
|
||||
title: "Secure emails from your own instance",
|
||||
description: (
|
||||
<>
|
||||
Plane can send useful emails to you and your users from your own instance without talking to the Internet.
|
||||
<>
|
||||
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
|
||||
<div className="flex items-center justify-between gap-4 border-b border-subtle mx-4 py-4 space-y-1 flex-shrink-0">
|
||||
<div className="py-4 space-y-1 flex-shrink-0">
|
||||
<div className="text-18 font-medium text-primary">Secure emails from your own instance</div>
|
||||
<div className="text-13 font-regular text-tertiary">
|
||||
Set it up below and please test your settings before you save them.
|
||||
<span className="text-danger">Misconfigs can lead to email bounces and errors.</span>
|
||||
Plane can send useful emails to you and your users from your own instance without talking to the Internet.
|
||||
<div className="text-13 font-regular text-tertiary">
|
||||
Set it up below and please test your settings before you save them.
|
||||
<span className="text-danger">Misconfigs can lead to email bounces and errors.</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
actions: isLoading ? (
|
||||
<Loader>
|
||||
<Loader.Item width="24px" height="16px" className="rounded-full" />
|
||||
</Loader>
|
||||
) : (
|
||||
<ToggleSwitch value={isSMTPEnabled} onChange={handleToggle} size="sm" disabled={isSubmitting} />
|
||||
),
|
||||
}}
|
||||
>
|
||||
{isSMTPEnabled && !isLoading && (
|
||||
<>
|
||||
{formattedConfig ? (
|
||||
<InstanceEmailForm config={formattedConfig} />
|
||||
) : (
|
||||
<Loader className="space-y-10">
|
||||
<Loader.Item height="50px" width="75%" />
|
||||
<Loader.Item height="50px" width="75%" />
|
||||
<Loader.Item height="50px" width="40%" />
|
||||
<Loader.Item height="50px" width="40%" />
|
||||
<Loader.Item height="50px" width="20%" />
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Loader>
|
||||
<Loader.Item width="24px" height="16px" className="rounded-full" />
|
||||
</Loader>
|
||||
) : (
|
||||
<ToggleSwitch value={isSMTPEnabled} onChange={handleToggle} size="sm" disabled={isSubmitting} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PageWrapper>
|
||||
</div>
|
||||
{isSMTPEnabled && !isLoading && (
|
||||
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
|
||||
{formattedConfig ? (
|
||||
<InstanceEmailForm config={formattedConfig} />
|
||||
) : (
|
||||
<Loader className="space-y-10">
|
||||
<Loader.Item height="50px" width="75%" />
|
||||
<Loader.Item height="50px" width="75%" />
|
||||
<Loader.Item height="50px" width="40%" />
|
||||
<Loader.Item height="50px" width="40%" />
|
||||
<Loader.Item height="50px" width="20%" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ export function SendTestEmailModal(props: Props) {
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform rounded-lg bg-surface-1 p-5 px-4 text-left shadow-raised-200 transition-all w-full sm:max-w-xl">
|
||||
<Dialog.Panel className="relative transform rounded-lg bg-surface-1 p-5 px-4 text-left shadow-custom-shadow-md transition-all w-full sm:max-w-xl">
|
||||
<h3 className="text-16 font-medium leading-6 text-primary">
|
||||
{sendEmailStep === ESendEmailSteps.SEND_EMAIL
|
||||
? "Send test email"
|
||||
@@ -121,7 +121,7 @@ export function SendTestEmailModal(props: Props) {
|
||||
</Button>
|
||||
{sendEmailStep === ESendEmailSteps.SEND_EMAIL && (
|
||||
<Button variant="primary" size="lg" loading={isLoading} onClick={handleSubmit} tabIndex={3}>
|
||||
{isLoading ? "Sending email" : "Send email"}
|
||||
{isLoading ? "Sending email..." : "Send email"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Telescope } from "lucide-react";
|
||||
// plane imports
|
||||
// types
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IInstance, IInstanceAdmin } from "@plane/types";
|
||||
// ui
|
||||
import { Input, ToggleSwitch } from "@plane/ui";
|
||||
// components
|
||||
import { ControllerInput } from "@/components/common/controller-input";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
// components
|
||||
import { IntercomConfig } from "./intercom";
|
||||
// hooks
|
||||
|
||||
export interface IGeneralConfigurationForm {
|
||||
instance: IInstance;
|
||||
@@ -27,8 +27,8 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { errors, isSubmitting },
|
||||
watch,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<Partial<IInstance>>({
|
||||
defaultValues: {
|
||||
instance_name: instance?.instance_name,
|
||||
@@ -105,14 +105,14 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="text-16 font-medium text-primary pb-1.5 border-b border-subtle">Chat + telemetry</div>
|
||||
<div className="space-y-4">
|
||||
<div className="text-16 font-medium text-primary">Chat + telemetry</div>
|
||||
<IntercomConfig isTelemetryEnabled={watch("is_telemetry_enabled") ?? false} />
|
||||
<div className="flex items-center gap-14">
|
||||
<div className="flex items-center gap-14 px-4 py-3 border border-subtle rounded-sm">
|
||||
<div className="grow flex items-center gap-4">
|
||||
<div className="shrink-0">
|
||||
<div className="flex items-center justify-center size-11 bg-layer-1 rounded-lg">
|
||||
<Telescope className="size-5 text-tertiary" />
|
||||
<div className="flex items-center justify-center w-10 h-10 bg-layer-1 rounded-full">
|
||||
<Telescope className="w-6 h-6 text-tertiary/80 p-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow">
|
||||
@@ -144,15 +144,8 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={() => {
|
||||
void handleSubmit(onSubmit)();
|
||||
}}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Saving" : "Save changes"}
|
||||
<Button variant="primary" size="lg" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : "Save changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,16 +44,16 @@ export const IntercomConfig = observer(function IntercomConfig(props: TIntercomC
|
||||
};
|
||||
|
||||
const enableIntercomConfig = () => {
|
||||
void submitInstanceConfigurations({ IS_INTERCOM_ENABLED: isIntercomEnabled ? "0" : "1" });
|
||||
submitInstanceConfigurations({ IS_INTERCOM_ENABLED: isIntercomEnabled ? "0" : "1" });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-14">
|
||||
<div className="flex items-center gap-14 px-4 py-3 border border-subtle rounded-sm">
|
||||
<div className="grow flex items-center gap-4">
|
||||
<div className="shrink-0">
|
||||
<div className="flex items-center justify-center size-11 bg-layer-1 rounded-lg">
|
||||
<MessageSquare className="size-5 text-tertiary p-0.5" />
|
||||
<div className="flex items-center justify-center w-10 h-10 bg-layer-1 rounded-full">
|
||||
<MessageSquare className="w-6 h-6 text-tertiary/80 p-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { PageWrapper } from "@/components/common/page-wrapper";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
// local imports
|
||||
import { GeneralConfigurationForm } from "./form";
|
||||
// types
|
||||
// components
|
||||
import type { Route } from "./+types/page";
|
||||
import { GeneralConfigurationForm } from "./form";
|
||||
|
||||
function GeneralPage() {
|
||||
const { instance, instanceAdmins } = useInstance();
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
header={{
|
||||
title: "General settings",
|
||||
description:
|
||||
"Change the name of your instance and instance admin e-mail addresses. Enable or disable telemetry in your instance.",
|
||||
}}
|
||||
>
|
||||
{instance && instanceAdmins && <GeneralConfigurationForm instance={instance} instanceAdmins={instanceAdmins} />}
|
||||
</PageWrapper>
|
||||
<>
|
||||
<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-subtle mx-4 py-4 space-y-1 flex-shrink-0">
|
||||
<div className="text-18 font-medium text-primary">General settings</div>
|
||||
<div className="text-13 font-regular text-tertiary">
|
||||
Change the name of your instance and instance admin e-mail addresses. Enable or disable telemetry in your
|
||||
instance.
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
|
||||
{instance && instanceAdmins && (
|
||||
<GeneralConfigurationForm instance={instance} instanceAdmins={instanceAdmins} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+34
-13
@@ -7,30 +7,51 @@ import { Breadcrumbs } from "@plane/ui";
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
// hooks
|
||||
import { useTheme } from "@/hooks/store";
|
||||
// local imports
|
||||
import { CORE_HEADER_SEGMENT_LABELS } from "./core";
|
||||
import { EXTENDED_HEADER_SEGMENT_LABELS } from "./extended";
|
||||
|
||||
export const HamburgerToggle = observer(function HamburgerToggle() {
|
||||
const { isSidebarCollapsed, toggleSidebar } = useTheme();
|
||||
return (
|
||||
<button
|
||||
className="size-7 rounded-sm flex justify-center items-center bg-layer-1 transition-all hover:bg-layer-1-hover cursor-pointer group md:hidden"
|
||||
<div
|
||||
className="w-7 h-7 rounded-sm flex justify-center items-center bg-layer-1 transition-all hover:bg-layer-1-hover cursor-pointer group md:hidden"
|
||||
onClick={() => toggleSidebar(!isSidebarCollapsed)}
|
||||
>
|
||||
<Menu size={14} className="text-secondary group-hover:text-primary transition-all" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const HEADER_SEGMENT_LABELS = {
|
||||
...CORE_HEADER_SEGMENT_LABELS,
|
||||
...EXTENDED_HEADER_SEGMENT_LABELS,
|
||||
};
|
||||
|
||||
export const AdminHeader = observer(function AdminHeader() {
|
||||
const pathName = usePathname();
|
||||
|
||||
const getHeaderTitle = (pathName: string) => {
|
||||
switch (pathName) {
|
||||
case "general":
|
||||
return "General";
|
||||
case "ai":
|
||||
return "Artificial Intelligence";
|
||||
case "email":
|
||||
return "Email";
|
||||
case "authentication":
|
||||
return "Authentication";
|
||||
case "image":
|
||||
return "Image";
|
||||
case "google":
|
||||
return "Google";
|
||||
case "github":
|
||||
return "GitHub";
|
||||
case "gitlab":
|
||||
return "GitLab";
|
||||
case "gitea":
|
||||
return "Gitea";
|
||||
case "workspace":
|
||||
return "Workspace";
|
||||
case "create":
|
||||
return "Create";
|
||||
default:
|
||||
return pathName.toUpperCase();
|
||||
}
|
||||
};
|
||||
|
||||
// Function to dynamically generate breadcrumb items based on pathname
|
||||
const generateBreadcrumbItems = (pathname: string) => {
|
||||
const pathSegments = pathname.split("/").slice(1); // removing the first empty string.
|
||||
@@ -40,14 +61,14 @@ export const AdminHeader = observer(function AdminHeader() {
|
||||
const breadcrumbItems = pathSegments.map((segment) => {
|
||||
currentUrl += "/" + segment;
|
||||
return {
|
||||
title: HEADER_SEGMENT_LABELS[segment] ?? segment.toUpperCase(),
|
||||
title: getHeaderTitle(segment),
|
||||
href: currentUrl,
|
||||
};
|
||||
});
|
||||
return breadcrumbItems;
|
||||
};
|
||||
|
||||
const breadcrumbItems = generateBreadcrumbItems(pathName || "");
|
||||
const breadcrumbItems = generateBreadcrumbItems(pathName);
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex h-header w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-subtle bg-surface-1 p-4">
|
||||
@@ -71,7 +71,7 @@ export function InstanceImageConfigForm(props: IInstanceImageConfigForm) {
|
||||
|
||||
<div>
|
||||
<Button variant="primary" size="lg" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
|
||||
{isSubmitting ? "Saving" : "Save changes"}
|
||||
{isSubmitting ? "Saving..." : "Save changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import { PageWrapper } from "@/components/common/page-wrapper";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
// types
|
||||
import type { Route } from "./+types/page";
|
||||
// local
|
||||
import type { Route } from "./+types/page";
|
||||
import { InstanceImageConfigForm } from "./form";
|
||||
|
||||
const InstanceImagePage = observer(function InstanceImagePage(_props: Route.ComponentProps) {
|
||||
@@ -17,21 +14,26 @@ const InstanceImagePage = observer(function InstanceImagePage(_props: Route.Comp
|
||||
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
header={{
|
||||
title: "Third-party image libraries",
|
||||
description: "Let your users search and choose images from third-party libraries",
|
||||
}}
|
||||
>
|
||||
{formattedConfig ? (
|
||||
<InstanceImageConfigForm config={formattedConfig} />
|
||||
) : (
|
||||
<Loader className="space-y-8">
|
||||
<Loader.Item height="50px" width="50%" />
|
||||
<Loader.Item height="50px" width="20%" />
|
||||
</Loader>
|
||||
)}
|
||||
</PageWrapper>
|
||||
<>
|
||||
<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-subtle mx-4 py-4 space-y-1 flex-shrink-0">
|
||||
<div className="text-18 font-medium text-primary">Third-party image libraries</div>
|
||||
<div className="text-13 font-regular text-tertiary">
|
||||
Let your users search and choose images from third-party libraries
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
|
||||
{formattedConfig ? (
|
||||
<InstanceImageConfigForm config={formattedConfig} />
|
||||
) : (
|
||||
<Loader className="space-y-8">
|
||||
<Loader.Item height="50px" width="50%" />
|
||||
<Loader.Item height="50px" width="20%" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@ import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Outlet } from "react-router";
|
||||
// components
|
||||
import { AdminHeader } from "@/components/common/header";
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { NewUserPopup } from "@/components/new-user-popup";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store";
|
||||
// local components
|
||||
import type { Route } from "./+types/layout";
|
||||
import { AdminHeader } from "./header";
|
||||
import { AdminSidebar } from "./sidebar";
|
||||
|
||||
function AdminLayout(_props: Route.ComponentProps) {
|
||||
|
||||
@@ -71,14 +71,14 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
|
||||
|
||||
useEffect(() => {
|
||||
if (csrfToken === undefined)
|
||||
void authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
|
||||
authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
|
||||
}, [csrfToken]);
|
||||
|
||||
return (
|
||||
<div className="flex max-h-header items-center gap-x-5 gap-y-2 border-b border-subtle px-4 py-2.5">
|
||||
<div className="flex max-h-header items-center gap-x-5 gap-y-2 border-b border-subtle px-4 py-3.5">
|
||||
<div className="h-full w-full truncate">
|
||||
<div
|
||||
className={`flex flex-grow items-center gap-x-2 truncate rounded-sm ${
|
||||
className={`flex flex-grow items-center gap-x-2 truncate rounded-sm py-1 ${
|
||||
isSidebarCollapsed ? "justify-center" : ""
|
||||
}`}
|
||||
>
|
||||
@@ -88,8 +88,8 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
|
||||
"cursor-default": !isSidebarCollapsed,
|
||||
})}
|
||||
>
|
||||
<div className="flex size-8 flex-shrink-0 items-center justify-center rounded-sm bg-layer-1">
|
||||
<UserCog2 className="size-5 text-primary" />
|
||||
<div className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-sm bg-layer-1">
|
||||
<UserCog2 className="h-5 w-5 text-secondary" />
|
||||
</div>
|
||||
</Menu.Button>
|
||||
{isSidebarCollapsed && (
|
||||
@@ -109,7 +109,7 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
|
||||
|
||||
{!isSidebarCollapsed && (
|
||||
<div className="flex w-full gap-2">
|
||||
<h4 className="grow truncate text-body-md-medium text-primary">Instance admin</h4>
|
||||
<h4 className="grow truncate text-14 font-medium text-secondary">Instance admin</h4>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -123,7 +123,7 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
|
||||
src={getFileURL(currentUser.avatar_url)}
|
||||
size={24}
|
||||
shape="square"
|
||||
className="!text-body-sm-medium"
|
||||
className="!text-14"
|
||||
/>
|
||||
</Menu.Button>
|
||||
|
||||
|
||||
@@ -9,9 +9,11 @@ import { DiscordIcon, GithubIcon, PageIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useInstance, useTheme } from "@/hooks/store";
|
||||
import { useTheme } from "@/hooks/store";
|
||||
// assets
|
||||
|
||||
import packageJson from "package.json";
|
||||
|
||||
const helpOptions = [
|
||||
{
|
||||
name: "Documentation",
|
||||
@@ -34,7 +36,6 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
|
||||
// states
|
||||
const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false);
|
||||
// store
|
||||
const { instance } = useInstance();
|
||||
const { isSidebarCollapsed, toggleSidebar } = useTheme();
|
||||
// refs
|
||||
const helpOptionsRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -54,9 +55,9 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
|
||||
<Tooltip tooltipContent="Redirect to Plane" position="right" className="ml-4" disabled={!isSidebarCollapsed}>
|
||||
<a
|
||||
href={redirectionLink}
|
||||
className={`relative px-2 py-1 flex items-center gap-1 rounded-sm bg-layer-1 text-body-xs-medium text-secondary whitespace-nowrap`}
|
||||
className={`relative px-2 py-1.5 flex items-center gap-2 font-medium rounded-sm border border-accent-strong/20 bg-accent-primary/10 text-11 text-accent-secondary whitespace-nowrap`}
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
<ExternalLink size={14} />
|
||||
{!isSidebarCollapsed && "Redirect to Plane"}
|
||||
</a>
|
||||
</Tooltip>
|
||||
@@ -68,7 +69,7 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
|
||||
}`}
|
||||
onClick={() => setIsNeedHelpOpen((prev) => !prev)}
|
||||
>
|
||||
<HelpCircle className="size-4" />
|
||||
<HelpCircle className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip tooltipContent="Toggle sidebar" position={isSidebarCollapsed ? "right" : "top"} className="ml-4">
|
||||
@@ -79,7 +80,7 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
|
||||
}`}
|
||||
onClick={() => toggleSidebar(!isSidebarCollapsed)}
|
||||
>
|
||||
<MoveLeft className={`size-4 duration-300 ${isSidebarCollapsed ? "rotate-180" : ""}`} />
|
||||
<MoveLeft className={`h-3.5 w-3.5 duration-300 ${isSidebarCollapsed ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -97,7 +98,7 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
|
||||
<div
|
||||
className={`absolute bottom-2 min-w-[10rem] z-[15] ${
|
||||
isSidebarCollapsed ? "left-full" : "-left-[75px]"
|
||||
} divide-y divide-subtle-1 whitespace-nowrap rounded-sm bg-surface-1 p-1 shadow-raised-100`}
|
||||
} divide-y divide-subtle-1 whitespace-nowrap rounded-sm bg-surface-1 p-1 shadow-custom-shadow-xs`}
|
||||
ref={helpOptionsRef}
|
||||
>
|
||||
<div className="space-y-1 pb-2">
|
||||
@@ -107,7 +108,7 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
|
||||
<Link href={href} key={name} target="_blank">
|
||||
<div className="flex items-center gap-x-2 rounded-sm px-2 py-1 text-11 hover:bg-layer-1-hover">
|
||||
<div className="grid flex-shrink-0 place-items-center">
|
||||
<Icon className="h-3.5 w-3.5 text-secondary" />
|
||||
<Icon className="h-3.5 w-3.5 text-secondary" width={14} height={14} />
|
||||
</div>
|
||||
<span className="text-11">{name}</span>
|
||||
</div>
|
||||
@@ -128,7 +129,7 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="px-2 pb-1 pt-2 text-10">Version: v{instance?.current_version}</div>
|
||||
<div className="px-2 pb-1 pt-2 text-10">Version: v{packageJson.version}</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,58 @@
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react";
|
||||
// plane internal packages
|
||||
import { WorkspaceIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useTheme } from "@/hooks/store";
|
||||
import { useSidebarMenu } from "@/hooks/use-sidebar-menu";
|
||||
|
||||
const INSTANCE_ADMIN_LINKS = [
|
||||
{
|
||||
Icon: Cog,
|
||||
name: "General",
|
||||
description: "Identify your instances and get key details.",
|
||||
href: `/general/`,
|
||||
},
|
||||
{
|
||||
Icon: WorkspaceIcon,
|
||||
name: "Workspaces",
|
||||
description: "Manage all workspaces on this instance.",
|
||||
href: `/workspace/`,
|
||||
},
|
||||
{
|
||||
Icon: Mail,
|
||||
name: "Email",
|
||||
description: "Configure your SMTP controls.",
|
||||
href: `/email/`,
|
||||
},
|
||||
{
|
||||
Icon: Lock,
|
||||
name: "Authentication",
|
||||
description: "Configure authentication modes.",
|
||||
href: `/authentication/`,
|
||||
},
|
||||
{
|
||||
Icon: BrainCog,
|
||||
name: "Artificial intelligence",
|
||||
description: "Configure your OpenAI creds.",
|
||||
href: `/ai/`,
|
||||
},
|
||||
{
|
||||
Icon: Image,
|
||||
name: "Images in Plane",
|
||||
description: "Allow third-party image libraries.",
|
||||
href: `/image/`,
|
||||
},
|
||||
];
|
||||
|
||||
export const AdminSidebarMenu = observer(function AdminSidebarMenu() {
|
||||
// router
|
||||
const pathName = usePathname();
|
||||
// store hooks
|
||||
const { isSidebarCollapsed, toggleSidebar } = useTheme();
|
||||
// derived values
|
||||
const sidebarMenu = useSidebarMenu();
|
||||
// router
|
||||
const pathName = usePathname();
|
||||
|
||||
const handleItemClick = () => {
|
||||
if (window.innerWidth < 768) {
|
||||
@@ -24,27 +62,40 @@ export const AdminSidebarMenu = observer(function AdminSidebarMenu() {
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col gap-2.5 overflow-y-scroll vertical-scrollbar scrollbar-sm px-4 py-4">
|
||||
{sidebarMenu.map((item, index) => {
|
||||
const isActive = item.href === pathName || pathName?.includes(item.href);
|
||||
{INSTANCE_ADMIN_LINKS.map((item, index) => {
|
||||
const isActive = item.href === pathName || pathName.includes(item.href);
|
||||
return (
|
||||
<Link key={index} href={item.href} onClick={handleItemClick}>
|
||||
<div>
|
||||
<Tooltip tooltipContent={item.name} position="right" className="ml-2" disabled={!isSidebarCollapsed}>
|
||||
<div
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-3 rounded-md px-3 py-2 outline-none transition-colors",
|
||||
{
|
||||
"text-primary !bg-layer-transparent-active": isActive,
|
||||
"text-secondary hover:bg-layer-transparent-hover active:bg-layer-transparent-active": !isActive,
|
||||
},
|
||||
`group flex w-full items-center gap-3 rounded-md px-3 py-2 outline-none transition-colors`,
|
||||
isActive
|
||||
? "bg-accent-primary/10 text-accent-primary"
|
||||
: "text-secondary hover:bg-layer-1-hover focus:bg-layer-1-hover",
|
||||
isSidebarCollapsed ? "justify-center" : "w-[260px]"
|
||||
)}
|
||||
>
|
||||
{<item.Icon className="h-4 w-4 flex-shrink-0" />}
|
||||
{!isSidebarCollapsed && (
|
||||
<div className="w-full ">
|
||||
<div className={cn(`text-body-xs-medium transition-colors`)}>{item.name}</div>
|
||||
<div className={cn(`text-caption-sm-regular transition-colors`)}>{item.description}</div>
|
||||
<div
|
||||
className={cn(
|
||||
`text-13 font-medium transition-colors`,
|
||||
isActive ? "text-accent-primary" : "text-secondary"
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
`text-10 transition-colors`,
|
||||
isActive ? "text-accent-secondary" : "text-placeholder"
|
||||
)}
|
||||
>
|
||||
{item.description}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { PageWrapper } from "@/components/common/page-wrapper";
|
||||
// types
|
||||
import type { Route } from "./+types/page";
|
||||
// local
|
||||
import { WorkspaceCreateForm } from "./form";
|
||||
|
||||
const WorkspaceCreatePage = observer(function WorkspaceCreatePage(_props: Route.ComponentProps) {
|
||||
return (
|
||||
<PageWrapper
|
||||
header={{
|
||||
title: "Create a new workspace on this instance.",
|
||||
description: "You will need to invite users from Workspace Settings after you create this workspace.",
|
||||
}}
|
||||
>
|
||||
<WorkspaceCreateForm />
|
||||
</PageWrapper>
|
||||
<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-subtle mx-4 py-4 space-y-1 flex-shrink-0">
|
||||
<div className="text-18 font-medium text-primary">Create a new workspace on this instance.</div>
|
||||
<div className="text-13 font-regular text-tertiary">
|
||||
You will need to invite users from Workspace Settings after you create this workspace.
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
|
||||
<WorkspaceCreateForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,13 +8,12 @@ import { Button, getButtonStyling } from "@plane/propel/button";
|
||||
import { setPromiseToast } from "@plane/propel/toast";
|
||||
import type { TInstanceConfigurationKeys } from "@plane/types";
|
||||
import { Loader, ToggleSwitch } from "@plane/ui";
|
||||
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { PageWrapper } from "@/components/common/page-wrapper";
|
||||
import { WorkspaceListItem } from "@/components/workspace/list-item";
|
||||
// hooks
|
||||
import { useInstance, useWorkspace } from "@/hooks/store";
|
||||
// types
|
||||
import type { Route } from "./+types/page";
|
||||
|
||||
const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props: Route.ComponentProps) {
|
||||
@@ -69,95 +68,99 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
|
||||
};
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
header={{
|
||||
title: "Workspaces on this instance",
|
||||
description: "See all workspaces and control who can create them.",
|
||||
}}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{formattedConfig ? (
|
||||
<div className={cn("w-full flex items-center gap-14 rounded-sm")}>
|
||||
<div className="flex grow items-center gap-4">
|
||||
<div className="grow">
|
||||
<div className="text-16 font-medium pb-1">Prevent anyone else from creating a workspace.</div>
|
||||
<div className={cn("font-regular leading-5 text-tertiary text-11")}>
|
||||
Toggling this on will let only you create workspaces. You will have to invite users to new workspaces.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`shrink-0 pr-4 ${isSubmitting && "opacity-70"}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(disableWorkspaceCreation))}
|
||||
onChange={() => {
|
||||
if (Boolean(parseInt(disableWorkspaceCreation)) === true) {
|
||||
updateConfig("DISABLE_WORKSPACE_CREATION", "0");
|
||||
} else {
|
||||
updateConfig("DISABLE_WORKSPACE_CREATION", "1");
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Loader>
|
||||
<Loader.Item height="50px" width="100%" />
|
||||
</Loader>
|
||||
)}
|
||||
{workspaceLoader !== "init-loader" ? (
|
||||
<>
|
||||
<div className="pt-6 flex items-center justify-between gap-2">
|
||||
<div className="flex flex-col items-start gap-x-2">
|
||||
<div className="flex items-center gap-2 text-16 font-medium">
|
||||
All workspaces on this instance <span className="text-tertiary">• {workspaceIds.length}</span>
|
||||
{workspaceLoader && ["mutation", "pagination"].includes(workspaceLoader) && (
|
||||
<LoaderIcon className="w-4 h-4 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
<div className={cn("font-regular leading-5 text-tertiary text-11")}>
|
||||
You can't yet delete workspaces and you can only go to the workspace if you are an Admin or a
|
||||
Member.
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/workspace/create" className={getButtonStyling("primary", "base")}>
|
||||
Create workspace
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
{workspaceIds.map((workspaceId) => (
|
||||
<WorkspaceListItem key={workspaceId} workspaceId={workspaceId} />
|
||||
))}
|
||||
</div>
|
||||
{hasNextPage && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="link"
|
||||
size="lg"
|
||||
onClick={() => fetchNextWorkspaces()}
|
||||
disabled={workspaceLoader === "pagination"}
|
||||
>
|
||||
Load more
|
||||
{workspaceLoader === "pagination" && <LoaderIcon className="w-3 h-3 animate-spin" />}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Loader className="space-y-10 py-8">
|
||||
<Loader.Item height="24px" width="20%" />
|
||||
<Loader.Item height="92px" width="100%" />
|
||||
<Loader.Item height="92px" width="100%" />
|
||||
<Loader.Item height="92px" width="100%" />
|
||||
</Loader>
|
||||
)}
|
||||
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
|
||||
<div className="flex items-center justify-between gap-4 border-b border-subtle mx-4 py-4 space-y-1 flex-shrink-0">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-18 font-medium text-primary">Workspaces on this instance</div>
|
||||
<div className="text-13 font-regular text-tertiary">See all workspaces and control who can create them.</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
|
||||
<div className="space-y-3">
|
||||
{formattedConfig ? (
|
||||
<div className={cn("w-full flex items-center gap-14 rounded-sm")}>
|
||||
<div className="flex grow items-center gap-4">
|
||||
<div className="grow">
|
||||
<div className="text-16 font-medium pb-1">Prevent anyone else from creating a workspace.</div>
|
||||
<div className={cn("font-regular leading-5 text-tertiary text-11")}>
|
||||
Toggling this on will let only you create workspaces. You will have to invite users to new
|
||||
workspaces.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`shrink-0 pr-4 ${isSubmitting && "opacity-70"}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(disableWorkspaceCreation))}
|
||||
onChange={() => {
|
||||
if (Boolean(parseInt(disableWorkspaceCreation)) === true) {
|
||||
updateConfig("DISABLE_WORKSPACE_CREATION", "0");
|
||||
} else {
|
||||
updateConfig("DISABLE_WORKSPACE_CREATION", "1");
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Loader>
|
||||
<Loader.Item height="50px" width="100%" />
|
||||
</Loader>
|
||||
)}
|
||||
{workspaceLoader !== "init-loader" ? (
|
||||
<>
|
||||
<div className="pt-6 flex items-center justify-between gap-2">
|
||||
<div className="flex flex-col items-start gap-x-2">
|
||||
<div className="flex items-center gap-2 text-16 font-medium">
|
||||
All workspaces on this instance <span className="text-tertiary">• {workspaceIds.length}</span>
|
||||
{workspaceLoader && ["mutation", "pagination"].includes(workspaceLoader) && (
|
||||
<LoaderIcon className="w-4 h-4 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
<div className={cn("font-regular leading-5 text-tertiary text-11")}>
|
||||
You can't yet delete workspaces and you can only go to the workspace if you are an Admin or a
|
||||
Member.
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/workspace/create" className={getButtonStyling("primary", "lg")}>
|
||||
Create workspace
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
{workspaceIds.map((workspaceId) => (
|
||||
<WorkspaceListItem key={workspaceId} workspaceId={workspaceId} />
|
||||
))}
|
||||
</div>
|
||||
{hasNextPage && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="link"
|
||||
size="lg"
|
||||
onClick={() => fetchNextWorkspaces()}
|
||||
disabled={workspaceLoader === "pagination"}
|
||||
>
|
||||
Load more
|
||||
{workspaceLoader === "pagination" && <LoaderIcon className="w-3 h-3 animate-spin" />}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Loader className="space-y-10 py-8">
|
||||
<Loader.Item height="24px" width="20%" />
|
||||
<Loader.Item height="92px" width="100%" />
|
||||
<Loader.Item height="92px" width="100%" />
|
||||
<Loader.Item height="92px" width="100%" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export function AuthBanner(props: TAuthBanner) {
|
||||
</div>
|
||||
<div className="w-full text-13 font-medium text-accent-primary">{bannerData?.message}</div>
|
||||
<div
|
||||
className="relative ml-auto w-6 h-6 rounded-xs flex justify-center items-center transition-all cursor-pointer hover:bg-accent-primary/20 text-accent-primary"
|
||||
className="relative ml-auto w-6 h-6 rounded-xs flex justify-center items-center transition-all cursor-pointer hover:bg-accent-primary/20 text-accent-primary/80"
|
||||
onClick={() => handleBannerData && handleBannerData(undefined)}
|
||||
>
|
||||
<CloseIcon className="w-4 h-4 flex-shrink-0" />
|
||||
|
||||
@@ -117,14 +117,14 @@ export const getBaseAuthenticationModes: (props: TGetBaseAuthenticationModeProps
|
||||
name: "Unique codes",
|
||||
description:
|
||||
"Log in or sign up for Plane using codes sent via email. You need to have set up SMTP to use this method.",
|
||||
icon: <Mails className="h-6 w-6 p-0.5 text-tertiary" />,
|
||||
icon: <Mails className="h-6 w-6 p-0.5 text-tertiary/80" />,
|
||||
config: <EmailCodesConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
key: "passwords-login",
|
||||
name: "Passwords",
|
||||
description: "Allow members to create accounts with passwords and use it with their email addresses to sign in.",
|
||||
icon: <KeyRound className="h-6 w-6 p-0.5 text-tertiary" />,
|
||||
icon: <KeyRound className="h-6 w-6 p-0.5 text-tertiary/80" />,
|
||||
config: <PasswordLoginConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { SWRConfig } from "swr";
|
||||
import { AppProgressBar } from "@/lib/b-progress";
|
||||
// local imports
|
||||
import { ToastWithTheme } from "./toast";
|
||||
import { StoreProvider } from "./store.provider";
|
||||
import { InstanceProvider } from "./instance.provider";
|
||||
import { UserProvider } from "./user.provider";
|
||||
import { InstanceProvider } from "./(all)/instance.provider";
|
||||
import { StoreProvider } from "./(all)/store.provider";
|
||||
import { ToastWithTheme } from "./(all)/toast";
|
||||
import { UserProvider } from "./(all)/user.provider";
|
||||
|
||||
const DEFAULT_SWR_CONFIG = {
|
||||
refreshWhenHidden: false,
|
||||
@@ -16,7 +15,7 @@ const DEFAULT_SWR_CONFIG = {
|
||||
errorRetryCount: 3,
|
||||
};
|
||||
|
||||
export function CoreProviders({ children }: { children: React.ReactNode }) {
|
||||
export function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider themes={["light", "dark"]} defaultTheme="system" enableSystem>
|
||||
<AppProgressBar />
|
||||
+2
-14
@@ -1,20 +1,15 @@
|
||||
import type { ReactNode } from "react";
|
||||
import * as Sentry from "@sentry/react-router";
|
||||
import { Links, Meta, Outlet, Scripts } from "react-router";
|
||||
import type { LinksFunction } from "react-router";
|
||||
import * as Sentry from "@sentry/react-router";
|
||||
import appleTouchIcon from "@/app/assets/favicon/apple-touch-icon.png?url";
|
||||
import favicon16 from "@/app/assets/favicon/favicon-16x16.png?url";
|
||||
import favicon32 from "@/app/assets/favicon/favicon-32x32.png?url";
|
||||
import faviconIco from "@/app/assets/favicon/favicon.ico?url";
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import globalStyles from "@/styles/globals.css?url";
|
||||
import { AppProviders } from "@/providers";
|
||||
import type { Route } from "./+types/root";
|
||||
// fonts
|
||||
import "@fontsource-variable/inter";
|
||||
import interVariableWoff2 from "@fontsource-variable/inter/files/inter-latin-wght-normal.woff2?url";
|
||||
import "@fontsource/material-symbols-rounded";
|
||||
import "@fontsource/ibm-plex-mono";
|
||||
import { AppProviders } from "./providers";
|
||||
|
||||
const APP_TITLE = "Plane | Simple, extensible, open-source project management tool.";
|
||||
const APP_DESCRIPTION =
|
||||
@@ -27,13 +22,6 @@ export const links: LinksFunction = () => [
|
||||
{ rel: "shortcut icon", href: faviconIco },
|
||||
{ rel: "manifest", href: `/site.webmanifest.json` },
|
||||
{ rel: "stylesheet", href: globalStyles },
|
||||
{
|
||||
rel: "preload",
|
||||
href: interVariableWoff2,
|
||||
as: "font",
|
||||
type: "font/woff2",
|
||||
crossOrigin: "anonymous",
|
||||
},
|
||||
];
|
||||
|
||||
export function Layout({ children }: { children: ReactNode }) {
|
||||
|
||||
+69
-20
@@ -1,61 +1,72 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { KeyRound, Mails } from "lucide-react";
|
||||
// types
|
||||
import type {
|
||||
TCoreInstanceAuthenticationModeKeys,
|
||||
TGetBaseAuthenticationModeProps,
|
||||
TInstanceAuthenticationMethodKeys,
|
||||
TInstanceAuthenticationModes,
|
||||
} from "@plane/types";
|
||||
// assets
|
||||
import { resolveGeneralTheme } from "@plane/utils";
|
||||
// components
|
||||
import giteaLogo from "@/app/assets/logos/gitea-logo.svg?url";
|
||||
import githubLightModeImage from "@/app/assets/logos/github-black.png?url";
|
||||
import githubDarkModeImage from "@/app/assets/logos/github-white.png?url";
|
||||
import gitlabLogo from "@/app/assets/logos/gitlab-logo.svg?url";
|
||||
import googleLogo from "@/app/assets/logos/google-logo.svg?url";
|
||||
// components
|
||||
import GitlabLogo from "@/app/assets/logos/gitlab-logo.svg?url";
|
||||
import GoogleLogo from "@/app/assets/logos/google-logo.svg?url";
|
||||
import OIDCLogo from "@/app/assets/logos/oidc-logo.svg?url";
|
||||
import SAMLLogo from "@/app/assets/logos/saml-logo.svg?url";
|
||||
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
|
||||
import { EmailCodesConfiguration } from "@/components/authentication/email-config-switch";
|
||||
import { GiteaConfiguration } from "@/components/authentication/gitea-config";
|
||||
import { GithubConfiguration } from "@/components/authentication/github-config";
|
||||
import { GitlabConfiguration } from "@/components/authentication/gitlab-config";
|
||||
import { GoogleConfiguration } from "@/components/authentication/google-config";
|
||||
import { PasswordLoginConfiguration } from "@/components/authentication/password-config-switch";
|
||||
// plane admin components
|
||||
import { UpgradeButton } from "@/plane-admin/components/common";
|
||||
// assets
|
||||
|
||||
export type TAuthenticationModeProps = {
|
||||
disabled: boolean;
|
||||
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
|
||||
};
|
||||
|
||||
// Authentication methods
|
||||
export const getCoreAuthenticationModesMap: (
|
||||
props: TGetBaseAuthenticationModeProps
|
||||
) => Record<TCoreInstanceAuthenticationModeKeys, TInstanceAuthenticationModes> = ({
|
||||
export const getAuthenticationModes: (props: TGetBaseAuthenticationModeProps) => TInstanceAuthenticationModes[] = ({
|
||||
disabled,
|
||||
updateConfig,
|
||||
resolvedTheme,
|
||||
}) => ({
|
||||
"unique-codes": {
|
||||
}) => [
|
||||
{
|
||||
key: "unique-codes",
|
||||
name: "Unique codes",
|
||||
description:
|
||||
"Log in or sign up for Plane using codes sent via email. You need to have set up SMTP to use this method.",
|
||||
icon: <Mails className="h-6 w-6 p-0.5 text-tertiary" />,
|
||||
icon: <Mails className="h-6 w-6 p-0.5 text-tertiary/80" />,
|
||||
config: <EmailCodesConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
"passwords-login": {
|
||||
{
|
||||
key: "passwords-login",
|
||||
name: "Passwords",
|
||||
description: "Allow members to create accounts with passwords and use it with their email addresses to sign in.",
|
||||
icon: <KeyRound className="h-6 w-6 p-0.5 text-tertiary" />,
|
||||
icon: <KeyRound className="h-6 w-6 p-0.5 text-tertiary/80" />,
|
||||
config: <PasswordLoginConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
google: {
|
||||
{
|
||||
key: "google",
|
||||
name: "Google",
|
||||
description: "Allow members to log in or sign up for Plane with their Google accounts.",
|
||||
icon: <img src={googleLogo} height={20} width={20} alt="Google Logo" />,
|
||||
icon: <img src={GoogleLogo} height={20} width={20} alt="Google Logo" />,
|
||||
config: <GoogleConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
github: {
|
||||
{
|
||||
key: "github",
|
||||
name: "GitHub",
|
||||
description: "Allow members to log in or sign up for Plane with their GitHub accounts.",
|
||||
icon: (
|
||||
<img
|
||||
src={resolvedTheme === "dark" ? githubDarkModeImage : githubLightModeImage}
|
||||
src={resolveGeneralTheme(resolvedTheme) === "dark" ? githubDarkModeImage : githubLightModeImage}
|
||||
height={20}
|
||||
width={20}
|
||||
alt="GitHub Logo"
|
||||
@@ -63,18 +74,56 @@ export const getCoreAuthenticationModesMap: (
|
||||
),
|
||||
config: <GithubConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
gitlab: {
|
||||
{
|
||||
key: "gitlab",
|
||||
name: "GitLab",
|
||||
description: "Allow members to log in or sign up to plane with their GitLab accounts.",
|
||||
icon: <img src={gitlabLogo} height={20} width={20} alt="GitLab Logo" />,
|
||||
icon: <img src={GitlabLogo} height={20} width={20} alt="GitLab Logo" />,
|
||||
config: <GitlabConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
gitea: {
|
||||
{
|
||||
key: "gitea",
|
||||
name: "Gitea",
|
||||
description: "Allow members to log in or sign up to plane with their Gitea accounts.",
|
||||
icon: <img src={giteaLogo} height={20} width={20} alt="Gitea Logo" />,
|
||||
config: <GiteaConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
key: "oidc",
|
||||
name: "OIDC",
|
||||
description: "Authenticate your users via the OpenID Connect protocol.",
|
||||
icon: <img src={OIDCLogo} height={22} width={22} alt="OIDC Logo" />,
|
||||
config: <UpgradeButton />,
|
||||
unavailable: true,
|
||||
},
|
||||
{
|
||||
key: "saml",
|
||||
name: "SAML",
|
||||
description: "Authenticate your users via the Security Assertion Markup Language protocol.",
|
||||
icon: <img src={SAMLLogo} height={22} width={22} alt="SAML Logo" className="pl-0.5" />,
|
||||
config: <UpgradeButton />,
|
||||
unavailable: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const AuthenticationModes = observer(function AuthenticationModes(props: TAuthenticationModeProps) {
|
||||
const { disabled, updateConfig } = props;
|
||||
// next-themes
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{getAuthenticationModes({ disabled, updateConfig, resolvedTheme }).map((method) => (
|
||||
<AuthenticationMethodCard
|
||||
key={method.key}
|
||||
name={method.name}
|
||||
description={method.description}
|
||||
icon={method.icon}
|
||||
config={method.config}
|
||||
disabled={disabled}
|
||||
unavailable={method.unavailable}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./authentication-modes";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./upgrade-button";
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from "react";
|
||||
// icons
|
||||
import { SquareArrowOutUpRight } from "lucide-react";
|
||||
// plane internal packages
|
||||
import { getButtonStyling } from "@plane/propel/button";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
export function UpgradeButton() {
|
||||
return (
|
||||
<a
|
||||
href="https://plane.so/pricing?mode=self-hosted"
|
||||
target="_blank"
|
||||
className={cn(getButtonStyling("primary", "base"))}
|
||||
rel="noreferrer"
|
||||
>
|
||||
Upgrade
|
||||
<SquareArrowOutUpRight className="h-3.5 w-3.5 p-0.5" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export function AuthenticationMethodCard(props: Props) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("w-full flex items-center gap-14 rounded-lg bg-layer-2", {
|
||||
className={cn("w-full flex items-center gap-14 rounded-sm", {
|
||||
"px-4 py-3 border border-subtle": withBorder,
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -44,7 +44,7 @@ export const GiteaConfiguration = observer(function GiteaConfiguration(props: Pr
|
||||
</div>
|
||||
) : (
|
||||
<Link href="/authentication/gitea" className={cn(getButtonStyling("secondary", "base"), "text-tertiary")}>
|
||||
<Settings2 className="h-4 w-4 p-0.5 text-tertiary" />
|
||||
<Settings2 className="h-4 w-4 p-0.5 text-tertiary/80" />
|
||||
Configure
|
||||
</Link>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
// icons
|
||||
@@ -42,7 +43,7 @@ export const GithubConfiguration = observer(function GithubConfiguration(props:
|
||||
</div>
|
||||
) : (
|
||||
<Link href="/authentication/github" className={cn(getButtonStyling("secondary", "base"), "text-tertiary")}>
|
||||
<Settings2 className="h-4 w-4 p-0.5 text-tertiary" />
|
||||
<Settings2 className="h-4 w-4 p-0.5 text-tertiary/80" />
|
||||
Configure
|
||||
</Link>
|
||||
)}
|
||||
|
||||
@@ -42,7 +42,7 @@ export const GitlabConfiguration = observer(function GitlabConfiguration(props:
|
||||
</div>
|
||||
) : (
|
||||
<Link href="/authentication/gitlab" className={cn(getButtonStyling("secondary", "base"), "text-tertiary")}>
|
||||
<Settings2 className="h-4 w-4 p-0.5 text-tertiary" />
|
||||
<Settings2 className="h-4 w-4 p-0.5 text-tertiary/80" />
|
||||
Configure
|
||||
</Link>
|
||||
)}
|
||||
|
||||
@@ -42,7 +42,7 @@ export const GoogleConfiguration = observer(function GoogleConfiguration(props:
|
||||
</div>
|
||||
) : (
|
||||
<Link href="/authentication/google" className={cn(getButtonStyling("secondary", "base"), "text-tertiary")}>
|
||||
<Settings2 className="h-4 w-4 p-0.5 text-tertiary" />
|
||||
<Settings2 className="h-4 w-4 p-0.5 text-tertiary/80" />
|
||||
Configure
|
||||
</Link>
|
||||
)}
|
||||
|
||||
@@ -39,7 +39,7 @@ export function ConfirmDiscardModal(props: Props) {
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-surface-1 text-left shadow-raised-200 transition-all sm:my-8 sm:w-[30rem]">
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-surface-1 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-[30rem]">
|
||||
<div className="px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="mt-3 text-center sm:mt-0 sm:text-left">
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { Control, FieldPath, FieldValues } from "react-hook-form";
|
||||
import { Controller } from "react-hook-form";
|
||||
// plane internal packages
|
||||
import { ToggleSwitch } from "@plane/ui";
|
||||
|
||||
type Props<T extends FieldValues = FieldValues> = {
|
||||
control: Control<T>;
|
||||
field: TControllerSwitchFormField<T>;
|
||||
};
|
||||
|
||||
export type TControllerSwitchFormField<T extends FieldValues = FieldValues> = {
|
||||
name: FieldPath<T>;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export function ControllerSwitch<T extends FieldValues>(props: Props<T>) {
|
||||
const {
|
||||
control,
|
||||
field: { name, label },
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<h4 className="text-sm text-custom-text-300">Refresh user attributes from {label} during sign in</h4>
|
||||
<div className="relative">
|
||||
<Controller
|
||||
control={control}
|
||||
name={name as FieldPath<T>}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
const parsedValue = Number.parseInt(typeof value === "string" ? value : String(value ?? "0"), 10);
|
||||
const isOn = !Number.isNaN(parsedValue) && parsedValue !== 0;
|
||||
return <ToggleSwitch value={isOn} onChange={() => onChange(isOn ? "0" : "1")} size="sm" />;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
export const CORE_HEADER_SEGMENT_LABELS: Record<string, string> = {
|
||||
general: "General",
|
||||
ai: "Artificial Intelligence",
|
||||
email: "Email",
|
||||
authentication: "Authentication",
|
||||
image: "Image",
|
||||
google: "Google",
|
||||
github: "GitHub",
|
||||
gitlab: "GitLab",
|
||||
gitea: "Gitea",
|
||||
workspace: "Workspace",
|
||||
create: "Create",
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export const EXTENDED_HEADER_SEGMENT_LABELS: Record<string, string> = {};
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type TPageWrapperProps = {
|
||||
children: ReactNode;
|
||||
header?: {
|
||||
title: string;
|
||||
description: string | ReactNode;
|
||||
actions?: ReactNode;
|
||||
};
|
||||
customHeader?: ReactNode;
|
||||
size?: "lg" | "md";
|
||||
};
|
||||
|
||||
export const PageWrapper = (props: TPageWrapperProps) => {
|
||||
const { children, header, customHeader, size = "md" } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("mx-auto w-full h-full space-y-6 py-4", {
|
||||
"md:px-4 max-w-[1000px] 2xl:max-w-[1200px]": size === "md",
|
||||
"px-4 lg:px-12": size === "lg",
|
||||
})}
|
||||
>
|
||||
{customHeader ? (
|
||||
<div className="border-b border-subtle mx-4 py-4 space-y-1 shrink-0">{customHeader}</div>
|
||||
) : (
|
||||
header && (
|
||||
<div className="flex items-center justify-between gap-4 border-b border-subtle mx-4 py-4 space-y-1 shrink-0">
|
||||
<div className={header.actions ? "flex flex-col gap-1" : "space-y-1"}>
|
||||
<div className="text-primary text-h5-semibold">{header.title}</div>
|
||||
<div className="text-secondary text-body-sm-regular">{header.description}</div>
|
||||
</div>
|
||||
{header.actions && <div className="shrink-0">{header.actions}</div>}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-sm px-4 pb-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -54,13 +54,13 @@ const defaultFromData: TFormData = {
|
||||
export function InstanceSetupForm() {
|
||||
// search params
|
||||
const searchParams = useSearchParams();
|
||||
const firstNameParam = searchParams?.get("first_name") || undefined;
|
||||
const lastNameParam = searchParams?.get("last_name") || undefined;
|
||||
const companyParam = searchParams?.get("company") || undefined;
|
||||
const emailParam = searchParams?.get("email") || undefined;
|
||||
const isTelemetryEnabledParam = (searchParams?.get("is_telemetry_enabled") === "True" ? true : false) || true;
|
||||
const errorCode = searchParams?.get("error_code") || undefined;
|
||||
const errorMessage = searchParams?.get("error_message") || undefined;
|
||||
const firstNameParam = searchParams.get("first_name") || undefined;
|
||||
const lastNameParam = searchParams.get("last_name") || undefined;
|
||||
const companyParam = searchParams.get("company") || undefined;
|
||||
const emailParam = searchParams.get("email") || undefined;
|
||||
const isTelemetryEnabledParam = (searchParams.get("is_telemetry_enabled") === "True" ? true : false) || true;
|
||||
const errorCode = searchParams.get("error_code") || undefined;
|
||||
const errorMessage = searchParams.get("error_message") || undefined;
|
||||
// state
|
||||
const [showPassword, setShowPassword] = useState({
|
||||
password: false,
|
||||
@@ -238,7 +238,7 @@ export function InstanceSetupForm() {
|
||||
name="password"
|
||||
type={showPassword.password ? "text" : "password"}
|
||||
inputSize="md"
|
||||
placeholder="New password"
|
||||
placeholder="New password..."
|
||||
value={formData.password}
|
||||
onChange={(e) => handleFormChange("password", e.target.value)}
|
||||
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD ? true : false}
|
||||
|
||||
@@ -23,13 +23,13 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace
|
||||
key={workspaceId}
|
||||
href={`${WEB_BASE_URL}/${encodeURIComponent(workspace.slug)}`}
|
||||
target="_blank"
|
||||
className="group flex items-center justify-between p-3 gap-2.5 truncate border border-subtle hover:border-subtle-1 bg-layer-1 hover:bg-layer-1-hover hover:shadow-raised-100 rounded-lg"
|
||||
className="group flex items-center justify-between p-4 gap-2.5 truncate border border-subtle/70 hover:border-subtle bg-layer-1 hover:bg-layer-1-hover rounded-md"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<span
|
||||
className={`relative flex h-8 w-8 flex-shrink-0 items-center justify-center p-2 mt-1 text-11 uppercase ${
|
||||
!workspace?.logo_url && "rounded-lg bg-accent-primary text-on-color"
|
||||
!workspace?.logo_url && "rounded-sm bg-accent-primary text-on-color"
|
||||
}`}
|
||||
>
|
||||
{workspace?.logo_url && workspace.logo_url !== "" ? (
|
||||
@@ -75,7 +75,7 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<ExternalLink size={16} className="text-placeholder group-hover:text-secondary" />
|
||||
<ExternalLink size={14} className="text-placeholder group-hover:text-secondary" />
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { TInstanceAuthenticationModes } from "@plane/types";
|
||||
import { getCoreAuthenticationModesMap } from "./core";
|
||||
import type { TGetAuthenticationModeProps } from "./types";
|
||||
|
||||
export const useAuthenticationModes = (props: TGetAuthenticationModeProps): TInstanceAuthenticationModes[] => {
|
||||
// derived values
|
||||
const authenticationModes = getCoreAuthenticationModesMap(props);
|
||||
|
||||
const availableAuthenticationModes: TInstanceAuthenticationModes[] = [
|
||||
authenticationModes["unique-codes"],
|
||||
authenticationModes["passwords-login"],
|
||||
authenticationModes["google"],
|
||||
authenticationModes["github"],
|
||||
authenticationModes["gitlab"],
|
||||
authenticationModes["gitea"],
|
||||
];
|
||||
|
||||
return availableAuthenticationModes;
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
|
||||
|
||||
export type TGetAuthenticationModeProps = {
|
||||
disabled: boolean;
|
||||
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
|
||||
resolvedTheme: string | undefined;
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useContext } from "react";
|
||||
// store
|
||||
import { StoreContext } from "@/providers/store.provider";
|
||||
import { StoreContext } from "@/app/(all)/store.provider";
|
||||
import type { IInstanceStore } from "@/store/instance.store";
|
||||
|
||||
export const useInstance = (): IInstanceStore => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useContext } from "react";
|
||||
// store
|
||||
import { StoreContext } from "@/providers/store.provider";
|
||||
import { StoreContext } from "@/app/(all)/store.provider";
|
||||
import type { IThemeStore } from "@/store/theme.store";
|
||||
|
||||
export const useTheme = (): IThemeStore => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useContext } from "react";
|
||||
// store
|
||||
import { StoreContext } from "@/providers/store.provider";
|
||||
import { StoreContext } from "@/app/(all)/store.provider";
|
||||
import type { IUserStore } from "@/store/user.store";
|
||||
|
||||
export const useUser = (): IUserStore => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useContext } from "react";
|
||||
// store
|
||||
import { StoreContext } from "@/providers/store.provider";
|
||||
import { StoreContext } from "@/app/(all)/store.provider";
|
||||
import type { IWorkspaceStore } from "@/store/workspace.store";
|
||||
|
||||
export const useWorkspace = (): IWorkspaceStore => {
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react";
|
||||
// plane imports
|
||||
import { WorkspaceIcon } from "@plane/propel/icons";
|
||||
// types
|
||||
import type { TSidebarMenuItem } from "./types";
|
||||
|
||||
export type TCoreSidebarMenuKey = "general" | "email" | "workspace" | "authentication" | "ai" | "image";
|
||||
|
||||
export const coreSidebarMenuLinks: Record<TCoreSidebarMenuKey, TSidebarMenuItem> = {
|
||||
general: {
|
||||
Icon: Cog,
|
||||
name: "General",
|
||||
description: "Identify your instances and get key details.",
|
||||
href: `/general/`,
|
||||
},
|
||||
email: {
|
||||
Icon: Mail,
|
||||
name: "Email",
|
||||
description: "Configure your SMTP controls.",
|
||||
href: `/email/`,
|
||||
},
|
||||
workspace: {
|
||||
Icon: WorkspaceIcon,
|
||||
name: "Workspaces",
|
||||
description: "Manage all workspaces on this instance.",
|
||||
href: `/workspace/`,
|
||||
},
|
||||
authentication: {
|
||||
Icon: Lock,
|
||||
name: "Authentication",
|
||||
description: "Configure authentication modes.",
|
||||
href: `/authentication/`,
|
||||
},
|
||||
ai: {
|
||||
Icon: BrainCog,
|
||||
name: "Artificial intelligence",
|
||||
description: "Configure your OpenAI creds.",
|
||||
href: `/ai/`,
|
||||
},
|
||||
image: {
|
||||
Icon: Image,
|
||||
name: "Images in Plane",
|
||||
description: "Allow third-party image libraries.",
|
||||
href: `/image/`,
|
||||
},
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
// local imports
|
||||
import { coreSidebarMenuLinks } from "./core";
|
||||
import type { TSidebarMenuItem } from "./types";
|
||||
|
||||
export function useSidebarMenu(): TSidebarMenuItem[] {
|
||||
return [
|
||||
coreSidebarMenuLinks.general,
|
||||
coreSidebarMenuLinks.email,
|
||||
coreSidebarMenuLinks.authentication,
|
||||
coreSidebarMenuLinks.workspace,
|
||||
coreSidebarMenuLinks.ai,
|
||||
coreSidebarMenuLinks.image,
|
||||
];
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
export type TSidebarMenuItem = {
|
||||
Icon: LucideIcon | React.ComponentType<{ className?: string }>;
|
||||
name: string;
|
||||
description: string;
|
||||
href: string;
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
export function ExtendedProviders({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { CoreProviders } from "./core";
|
||||
import { ExtendedProviders } from "./extended";
|
||||
|
||||
export function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<CoreProviders>
|
||||
<ExtendedProviders>{children}</ExtendedProviders>
|
||||
</CoreProviders>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "ce/components/authentication/authentication-modes";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./authentication-modes";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "ce/components/common";
|
||||
@@ -19,9 +19,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@bprogress/core": "catalog:",
|
||||
"@fontsource-variable/inter": "5.2.8",
|
||||
"@fontsource/ibm-plex-mono": "5.2.7",
|
||||
"@fontsource/material-symbols-rounded": "5.2.30",
|
||||
"@headlessui/react": "^1.7.19",
|
||||
"@plane/constants": "workspace:*",
|
||||
"@plane/hooks": "workspace:*",
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
/* progress bar */
|
||||
.progress-bar {
|
||||
fill: currentColor;
|
||||
color: var(--background-color-surface-1);
|
||||
color: rgba(var(--color-sidebar-background-100));
|
||||
}
|
||||
|
||||
/* Progress Bar Styles */
|
||||
:root {
|
||||
--bprogress-color: var(--background-color-accent-primary);
|
||||
--bprogress-color: rgb(var(--color-primary-100)) !important;
|
||||
--bprogress-height: 2.5px !important;
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
.bprogress .bar {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
--alpha(var(--background-color-accent-primary) / 80%) 0%,
|
||||
--alpha(var(--background-color-accent-primary) / 100%) 100%
|
||||
rgba(var(--color-primary-100), 0.8) 0%,
|
||||
rgba(var(--color-primary-100), 1) 100%
|
||||
) !important;
|
||||
will-change: width, opacity;
|
||||
}
|
||||
@@ -36,7 +36,7 @@
|
||||
.bprogress .peg {
|
||||
display: block;
|
||||
box-shadow:
|
||||
0 0 8px --alpha(var(--background-color-accent-primary) / 60%),
|
||||
0 0 4px --alpha(var(--background-color-accent-primary) / 40%) !important;
|
||||
0 0 8px rgba(var(--color-primary-100), 0.6),
|
||||
0 0 4px rgba(var(--color-primary-100), 0.4) !important;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.db.models import Q
|
||||
from django.urls import resolve
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import authentication
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import APIToken, Workspace
|
||||
from plane.db.models import APIToken
|
||||
|
||||
|
||||
class APIKeyAuthentication(authentication.BaseAuthentication):
|
||||
@@ -23,20 +22,13 @@ class APIKeyAuthentication(authentication.BaseAuthentication):
|
||||
def get_api_token(self, request):
|
||||
return request.headers.get(self.auth_header_name)
|
||||
|
||||
def validate_api_token(self, token, workspace_slug):
|
||||
def validate_api_token(self, token):
|
||||
try:
|
||||
api_token = APIToken.objects.get(
|
||||
Q(Q(expired_at__gt=timezone.now()) | Q(expired_at__isnull=True)),
|
||||
token=token,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
if workspace_slug:
|
||||
workspace = Workspace.objects.get(slug=workspace_slug)
|
||||
|
||||
if api_token.workspace_id != workspace.id:
|
||||
raise AuthenticationFailed("Given API token is not valid")
|
||||
|
||||
except APIToken.DoesNotExist:
|
||||
raise AuthenticationFailed("Given API token is not valid")
|
||||
|
||||
@@ -46,12 +38,10 @@ class APIKeyAuthentication(authentication.BaseAuthentication):
|
||||
return (api_token.user, api_token.token)
|
||||
|
||||
def authenticate(self, request):
|
||||
workspace_slug = resolve(request.path_info).kwargs.get("slug")
|
||||
|
||||
token = self.get_api_token(request=request)
|
||||
if not token:
|
||||
return None
|
||||
|
||||
# Validate the API token
|
||||
user, token = self.validate_api_token(token, workspace_slug)
|
||||
user, token = self.validate_api_token(token)
|
||||
return user, token
|
||||
|
||||
@@ -3,7 +3,6 @@ import os
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.throttling import SimpleRateThrottle
|
||||
from plane.db.models import APIToken
|
||||
|
||||
|
||||
class ApiKeyRateThrottle(SimpleRateThrottle):
|
||||
@@ -86,41 +85,3 @@ class ServiceTokenRateThrottle(SimpleRateThrottle):
|
||||
request.META["X-RateLimit-Reset"] = reset_time
|
||||
|
||||
return allowed
|
||||
|
||||
|
||||
class WorkspaceTokenRateThrottle(SimpleRateThrottle):
|
||||
scope = "workspace_token"
|
||||
rate = "60/minute"
|
||||
|
||||
def get_cache_key(self, request, view):
|
||||
api_key = request.headers.get("X-Api-Key")
|
||||
if not api_key:
|
||||
return None
|
||||
|
||||
return f"{self.scope}:{api_key}"
|
||||
|
||||
def allow_request(self, request, view):
|
||||
api_key = request.headers.get("X-Api-Key")
|
||||
|
||||
if api_key:
|
||||
token = APIToken.objects.filter(token=api_key).only("allowed_rate_limit").first()
|
||||
if token and token.allowed_rate_limit:
|
||||
self.rate = token.allowed_rate_limit
|
||||
|
||||
self.num_requests, self.duration = self.parse_rate(self.rate)
|
||||
|
||||
allowed = super().allow_request(request, view)
|
||||
|
||||
if allowed:
|
||||
now = self.timer()
|
||||
history = self.cache.get(self.key, [])
|
||||
|
||||
while history and history[-1] <= now - self.duration:
|
||||
history.pop()
|
||||
|
||||
available = self.num_requests - len(history)
|
||||
|
||||
request.META["X-RateLimit-Remaining"] = max(0, available)
|
||||
request.META["X-RateLimit-Reset"] = int(now + self.duration)
|
||||
|
||||
return allowed
|
||||
|
||||
@@ -3,7 +3,13 @@ import random
|
||||
from rest_framework import serializers
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Project, ProjectIdentifier, WorkspaceMember, State, Estimate
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
ProjectIdentifier,
|
||||
WorkspaceMember,
|
||||
State,
|
||||
Estimate,
|
||||
)
|
||||
|
||||
from plane.utils.content_validator import (
|
||||
validate_html_content,
|
||||
@@ -117,7 +123,6 @@ class ProjectCreateSerializer(BaseSerializer):
|
||||
|
||||
def create(self, validated_data):
|
||||
identifier = validated_data.get("identifier", "").strip().upper()
|
||||
|
||||
if identifier == "":
|
||||
raise serializers.ValidationError(detail="Project Identifier is required")
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ from rest_framework.generics import GenericAPIView
|
||||
# Module imports
|
||||
from plane.db.models.api import APIToken
|
||||
from plane.api.middleware.api_authentication import APIKeyAuthentication
|
||||
from plane.api.rate_limit import ApiKeyRateThrottle, ServiceTokenRateThrottle, WorkspaceTokenRateThrottle
|
||||
from plane.api.rate_limit import ApiKeyRateThrottle, ServiceTokenRateThrottle
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.utils.paginator import BasePaginator
|
||||
from plane.utils.core.mixins import ReadReplicaControlMixin
|
||||
@@ -60,20 +60,12 @@ class BaseAPIView(TimezoneMixin, GenericAPIView, ReadReplicaControlMixin, BasePa
|
||||
api_key = self.request.headers.get("X-Api-Key")
|
||||
|
||||
if api_key:
|
||||
api_token = APIToken.objects.filter(token=api_key)
|
||||
|
||||
service_token = api_token.filter(is_service=True).first()
|
||||
|
||||
workspace_token = api_token.filter(workspace_id__isnull=False).first()
|
||||
service_token = APIToken.objects.filter(token=api_key, is_service=True).first()
|
||||
|
||||
if service_token:
|
||||
throttle_classes.append(ServiceTokenRateThrottle())
|
||||
return throttle_classes
|
||||
|
||||
if workspace_token:
|
||||
throttle_classes.append(WorkspaceTokenRateThrottle())
|
||||
return throttle_classes
|
||||
|
||||
throttle_classes.append(ApiKeyRateThrottle())
|
||||
|
||||
return throttle_classes
|
||||
|
||||
@@ -210,9 +210,7 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
|
||||
"""
|
||||
try:
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
serializer = ProjectCreateSerializer(data={**request.data}, context={"workspace_id": workspace.id})
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
# Django import
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party import
|
||||
from rest_framework import serializers
|
||||
|
||||
# Module import
|
||||
from .base import BaseSerializer
|
||||
from plane.db.models import APIToken, APIActivityLog
|
||||
from rest_framework import serializers
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class APITokenSerializer(BaseSerializer):
|
||||
|
||||
@@ -13,7 +13,7 @@ from plane.db.models import (
|
||||
ProjectIdentifier,
|
||||
DeployBoard,
|
||||
ProjectPublicMember,
|
||||
IssueSequence,
|
||||
IssueSequence
|
||||
)
|
||||
from plane.utils.content_validator import (
|
||||
validate_html_content,
|
||||
|
||||
@@ -78,7 +78,6 @@ class UserMeSerializer(BaseSerializer):
|
||||
"is_password_autoset",
|
||||
"is_email_verified",
|
||||
"last_login_medium",
|
||||
"last_login_time",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from django.urls import path
|
||||
from plane.app.views import ApiTokenEndpoint, ServiceApiTokenEndpoint, WorkspaceAPITokenEndpoint
|
||||
from plane.app.views import ApiTokenEndpoint, ServiceApiTokenEndpoint
|
||||
|
||||
urlpatterns = [
|
||||
# API Tokens
|
||||
@@ -18,15 +18,5 @@ urlpatterns = [
|
||||
ServiceApiTokenEndpoint.as_view(),
|
||||
name="service-api-tokens",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/api-tokens/",
|
||||
WorkspaceAPITokenEndpoint.as_view(),
|
||||
name="workspace-api-tokens",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/api-tokens/<uuid:pk>/",
|
||||
WorkspaceAPITokenEndpoint.as_view(),
|
||||
name="workspace-api-tokens-details",
|
||||
),
|
||||
## End API Tokens
|
||||
]
|
||||
|
||||
@@ -161,7 +161,7 @@ from .module.issue import ModuleIssueViewSet
|
||||
|
||||
from .module.archive import ModuleArchiveUnarchiveEndpoint
|
||||
|
||||
from .api import ApiTokenEndpoint, ServiceApiTokenEndpoint, WorkspaceAPITokenEndpoint
|
||||
from .api import ApiTokenEndpoint, ServiceApiTokenEndpoint
|
||||
|
||||
from .page.base import (
|
||||
PageViewSet,
|
||||
|
||||
@@ -8,9 +8,10 @@ from rest_framework.request import Request
|
||||
from rest_framework import status
|
||||
|
||||
# Module import
|
||||
from plane.app.views.base import BaseAPIView
|
||||
from plane.db.models import APIToken
|
||||
from .base import BaseAPIView
|
||||
from plane.db.models import APIToken, Workspace
|
||||
from plane.app.serializers import APITokenSerializer, APITokenReadSerializer
|
||||
from plane.app.permissions import WorkspaceEntityPermission
|
||||
|
||||
|
||||
class ApiTokenEndpoint(BaseAPIView):
|
||||
@@ -36,11 +37,11 @@ class ApiTokenEndpoint(BaseAPIView):
|
||||
|
||||
def get(self, request: Request, pk: Optional[str] = None) -> Response:
|
||||
if pk is None:
|
||||
api_tokens = APIToken.objects.filter(user=request.user, is_service=False, workspace_id__isnull=True)
|
||||
api_tokens = APIToken.objects.filter(user=request.user, is_service=False)
|
||||
serializer = APITokenReadSerializer(api_tokens, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
else:
|
||||
api_tokens = APIToken.objects.get(user=request.user, pk=pk, workspace_id__isnull=True)
|
||||
api_tokens = APIToken.objects.get(user=request.user, pk=pk)
|
||||
serializer = APITokenReadSerializer(api_tokens)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -56,3 +57,28 @@ class ApiTokenEndpoint(BaseAPIView):
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class ServiceApiTokenEndpoint(BaseAPIView):
|
||||
permission_classes = [WorkspaceEntityPermission]
|
||||
|
||||
def post(self, request: Request, slug: str) -> Response:
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
api_token = APIToken.objects.filter(workspace=workspace, is_service=True).first()
|
||||
|
||||
if api_token:
|
||||
return Response({"token": str(api_token.token)}, status=status.HTTP_200_OK)
|
||||
else:
|
||||
# Check the user type
|
||||
user_type = 1 if request.user.is_bot else 0
|
||||
|
||||
api_token = APIToken.objects.create(
|
||||
label=str(uuid4().hex),
|
||||
description="Service Token",
|
||||
user=request.user,
|
||||
workspace=workspace,
|
||||
user_type=user_type,
|
||||
is_service=True,
|
||||
)
|
||||
return Response({"token": str(api_token.token)}, status=status.HTTP_201_CREATED)
|
||||
@@ -1,3 +0,0 @@
|
||||
from .base import ApiTokenEndpoint
|
||||
from .service import ServiceApiTokenEndpoint
|
||||
from .workspace import WorkspaceAPITokenEndpoint
|
||||
@@ -1,37 +0,0 @@
|
||||
# Python import
|
||||
from uuid import uuid4
|
||||
|
||||
# Third party
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.request import Request
|
||||
from rest_framework import status
|
||||
|
||||
# Module import
|
||||
from .base import BaseAPIView
|
||||
from plane.db.models import APIToken, Workspace
|
||||
from plane.app.permissions import WorkspaceEntityPermission
|
||||
|
||||
|
||||
class ServiceApiTokenEndpoint(BaseAPIView):
|
||||
permission_classes = [WorkspaceEntityPermission]
|
||||
|
||||
def post(self, request: Request, slug: str) -> Response:
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
api_token = APIToken.objects.filter(workspace=workspace, is_service=True).first()
|
||||
|
||||
if api_token:
|
||||
return Response({"token": str(api_token.token)}, status=status.HTTP_200_OK)
|
||||
else:
|
||||
# Check the user type
|
||||
user_type = 1 if request.user.is_bot else 0
|
||||
|
||||
api_token = APIToken.objects.create(
|
||||
label=str(uuid4().hex),
|
||||
description="Service Token",
|
||||
user=request.user,
|
||||
workspace=workspace,
|
||||
user_type=user_type,
|
||||
is_service=True,
|
||||
)
|
||||
return Response({"token": str(api_token.token)}, status=status.HTTP_201_CREATED)
|
||||
@@ -1,59 +0,0 @@
|
||||
# Python import
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
# Third party
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.request import Request
|
||||
from rest_framework import status
|
||||
|
||||
# Module import
|
||||
from plane.app.views import BaseAPIView
|
||||
from plane.db.models import APIToken, Workspace
|
||||
from plane.app.serializers import APITokenSerializer, APITokenReadSerializer
|
||||
from plane.app.permissions import WorkSpaceAdminPermission
|
||||
|
||||
|
||||
class WorkspaceAPITokenEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkSpaceAdminPermission,
|
||||
]
|
||||
|
||||
def post(self, request: Request, slug: str) -> Response:
|
||||
label = request.data.get("label", str(uuid4().hex))
|
||||
description = request.data.get("description", "")
|
||||
expired_at = request.data.get("expired_at", None)
|
||||
|
||||
# Check the user type
|
||||
user_type = 1 if request.user.is_bot else 0
|
||||
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
api_token = APIToken.objects.create(
|
||||
label=label,
|
||||
description=description,
|
||||
user=request.user,
|
||||
user_type=user_type,
|
||||
expired_at=expired_at,
|
||||
workspace=workspace,
|
||||
)
|
||||
|
||||
serializer = APITokenSerializer(api_token)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
def get(self, request: Request, slug: str, pk: Optional[str] = None) -> Response:
|
||||
if pk is None:
|
||||
api_tokens = APIToken.objects.filter(workspace__slug=slug, is_service=False, user=request.user)
|
||||
serializer = APITokenReadSerializer(api_tokens, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
else:
|
||||
api_tokens = APIToken.objects.get(workspace__slug=slug, pk=pk, user=request.user)
|
||||
serializer = APITokenReadSerializer(api_tokens)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
def delete(self, request: Request, slug: str, pk: str) -> Response:
|
||||
api_token = APIToken.objects.get(workspace__slug=slug, pk=pk, is_service=False, user=request.user)
|
||||
api_token.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -42,9 +42,7 @@ from plane.app.permissions import ROLE, allow_permission
|
||||
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
|
||||
from plane.license.utils.instance_value import get_configuration_value
|
||||
from plane.bgtasks.workspace_seed_task import workspace_seed
|
||||
from plane.bgtasks.event_tracking_task import track_event
|
||||
from plane.utils.url import contains_url
|
||||
from plane.utils.analytics_events import WORKSPACE_CREATED, WORKSPACE_DELETED
|
||||
|
||||
|
||||
class WorkSpaceViewSet(BaseViewSet):
|
||||
@@ -133,20 +131,6 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
|
||||
workspace_seed.delay(serializer.data["id"])
|
||||
|
||||
track_event.delay(
|
||||
user_id=request.user.id,
|
||||
event_name=WORKSPACE_CREATED,
|
||||
slug=data["slug"],
|
||||
event_properties={
|
||||
"user_id": request.user.id,
|
||||
"workspace_id": data["id"],
|
||||
"workspace_slug": data["slug"],
|
||||
"role": "owner",
|
||||
"workspace_name": data["name"],
|
||||
"created_at": data["created_at"],
|
||||
},
|
||||
)
|
||||
|
||||
return Response(data, status=status.HTTP_201_CREATED)
|
||||
return Response(
|
||||
[serializer.errors[error][0] for error in serializer.errors],
|
||||
@@ -180,19 +164,6 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
# Get the workspace
|
||||
workspace = self.get_object()
|
||||
self.remove_last_workspace_ids_from_user_settings(workspace.id)
|
||||
track_event.delay(
|
||||
user_id=request.user.id,
|
||||
event_name=WORKSPACE_DELETED,
|
||||
slug=workspace.slug,
|
||||
event_properties={
|
||||
"user_id": request.user.id,
|
||||
"workspace_id": workspace.id,
|
||||
"workspace_slug": workspace.slug,
|
||||
"role": "owner",
|
||||
"workspace_name": workspace.name,
|
||||
"deleted_at": str(timezone.now().isoformat()),
|
||||
},
|
||||
)
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
|
||||
|
||||
@@ -21,13 +21,12 @@ from plane.app.serializers import (
|
||||
WorkSpaceMemberSerializer,
|
||||
)
|
||||
from plane.app.views.base import BaseAPIView
|
||||
from plane.bgtasks.event_tracking_task import track_event
|
||||
from plane.bgtasks.event_tracking_task import workspace_invite_event
|
||||
from plane.bgtasks.workspace_invitation_task import workspace_invitation
|
||||
from plane.db.models import User, Workspace, WorkspaceMember, WorkspaceMemberInvite
|
||||
from plane.utils.cache import invalidate_cache, invalidate_cache_directly
|
||||
from plane.utils.host import base_host
|
||||
from plane.utils.ip_address import get_client_ip
|
||||
from plane.utils.analytics_events import USER_JOINED_WORKSPACE, USER_INVITED_TO_WORKSPACE
|
||||
from .. import BaseViewSet
|
||||
|
||||
|
||||
@@ -122,19 +121,6 @@ class WorkspaceInvitationsViewset(BaseViewSet):
|
||||
current_site,
|
||||
request.user.email,
|
||||
)
|
||||
track_event.delay(
|
||||
user_id=request.user.id,
|
||||
event_name=USER_INVITED_TO_WORKSPACE,
|
||||
slug=slug,
|
||||
event_properties={
|
||||
"user_id": request.user.id,
|
||||
"workspace_id": workspace.id,
|
||||
"workspace_slug": workspace.slug,
|
||||
"invitee_role": invitation.role,
|
||||
"invited_at": str(timezone.now()),
|
||||
"invitee_email": invitation.email,
|
||||
},
|
||||
)
|
||||
|
||||
return Response({"message": "Emails sent successfully"}, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -200,22 +186,20 @@ class WorkspaceJoinEndpoint(BaseAPIView):
|
||||
# Set the user last_workspace_id to the accepted workspace
|
||||
user.last_workspace_id = workspace_invite.workspace.id
|
||||
user.save()
|
||||
track_event.delay(
|
||||
user_id=user.id,
|
||||
event_name=USER_JOINED_WORKSPACE,
|
||||
slug=slug,
|
||||
event_properties={
|
||||
"user_id": user.id,
|
||||
"workspace_id": workspace_invite.workspace.id,
|
||||
"workspace_slug": workspace_invite.workspace.slug,
|
||||
"role": workspace_invite.role,
|
||||
"joined_at": str(timezone.now()),
|
||||
},
|
||||
)
|
||||
|
||||
# Delete the invitation
|
||||
workspace_invite.delete()
|
||||
|
||||
# Send event
|
||||
workspace_invite_event.delay(
|
||||
user=user.id if user is not None else None,
|
||||
email=email,
|
||||
user_agent=request.META.get("HTTP_USER_AGENT"),
|
||||
ip=get_client_ip(request=request),
|
||||
event_name="MEMBER_ACCEPTED",
|
||||
accepted_from="EMAIL",
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"message": "Workspace Invitation Accepted"},
|
||||
status=status.HTTP_200_OK,
|
||||
@@ -268,20 +252,6 @@ class UserWorkspaceInvitationsViewSet(BaseViewSet):
|
||||
is_active=True, role=invitation.role
|
||||
)
|
||||
|
||||
# Track event
|
||||
track_event.delay(
|
||||
user_id=request.user.id,
|
||||
event_name=USER_JOINED_WORKSPACE,
|
||||
slug=invitation.workspace.slug,
|
||||
event_properties={
|
||||
"user_id": request.user.id,
|
||||
"workspace_id": invitation.workspace.id,
|
||||
"workspace_slug": invitation.workspace.slug,
|
||||
"role": invitation.role,
|
||||
"joined_at": str(timezone.now()),
|
||||
},
|
||||
)
|
||||
|
||||
# Bulk create the user for all the workspaces
|
||||
WorkspaceMember.objects.bulk_create(
|
||||
[
|
||||
|
||||
@@ -21,7 +21,6 @@ from plane.bgtasks.user_activation_email_task import user_activation_email
|
||||
from plane.utils.host import base_host
|
||||
from plane.utils.ip_address import get_client_ip
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.settings.storage import S3Storage
|
||||
|
||||
|
||||
class Adapter:
|
||||
@@ -91,9 +90,9 @@ class Adapter:
|
||||
"""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")}
|
||||
])
|
||||
(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():
|
||||
@@ -109,20 +108,6 @@ class Adapter:
|
||||
def get_avatar_download_headers(self):
|
||||
return {}
|
||||
|
||||
def check_sync_enabled(self):
|
||||
"""Check if sync is enabled for the provider"""
|
||||
provider_config_map = {
|
||||
"google": "ENABLE_GOOGLE_SYNC",
|
||||
"github": "ENABLE_GITHUB_SYNC",
|
||||
"gitlab": "ENABLE_GITLAB_SYNC",
|
||||
"gitea": "ENABLE_GITEA_SYNC",
|
||||
}
|
||||
config_key = provider_config_map.get(self.provider)
|
||||
if config_key:
|
||||
(enabled,) = get_configuration_value([{"key": config_key, "default": os.environ.get(config_key, "0")}])
|
||||
return enabled == "1"
|
||||
return False
|
||||
|
||||
def download_and_upload_avatar(self, avatar_url, user):
|
||||
"""
|
||||
Downloads avatar from OAuth provider and uploads to our storage.
|
||||
@@ -171,6 +156,9 @@ class Adapter:
|
||||
# Generate unique filename
|
||||
filename = f"{uuid.uuid4().hex}-user-avatar.{extension}"
|
||||
|
||||
# Upload to S3/MinIO storage
|
||||
from plane.settings.storage import S3Storage
|
||||
|
||||
storage = S3Storage(request=self.request)
|
||||
|
||||
# Create file-like object
|
||||
@@ -220,59 +208,6 @@ class Adapter:
|
||||
user.save()
|
||||
return user
|
||||
|
||||
def delete_old_avatar(self, user):
|
||||
"""Delete the old avatar if it exists"""
|
||||
try:
|
||||
if user.avatar_asset:
|
||||
asset = FileAsset.objects.get(pk=user.avatar_asset_id)
|
||||
storage = S3Storage(request=self.request)
|
||||
storage.delete_files(object_names=[asset.asset.name])
|
||||
|
||||
# Delete the user avatar
|
||||
asset.delete()
|
||||
user.avatar_asset = None
|
||||
user.avatar = ""
|
||||
user.save()
|
||||
return
|
||||
except FileAsset.DoesNotExist:
|
||||
pass
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
def sync_user_data(self, user):
|
||||
# Update user details
|
||||
first_name = self.user_data.get("user", {}).get("first_name", "")
|
||||
last_name = self.user_data.get("user", {}).get("last_name", "")
|
||||
user.first_name = first_name if first_name else ""
|
||||
user.last_name = last_name if last_name else ""
|
||||
|
||||
# Get email
|
||||
email = self.user_data.get("email")
|
||||
|
||||
# Get display name
|
||||
display_name = self.user_data.get("user", {}).get("display_name")
|
||||
# If display name is not provided, generate a random display name
|
||||
if not display_name:
|
||||
display_name = User.get_display_name(email)
|
||||
|
||||
# Set display name
|
||||
user.display_name = display_name
|
||||
|
||||
# Download and upload avatar only if the avatar is different from the one in the storage
|
||||
avatar = self.user_data.get("user", {}).get("avatar", "")
|
||||
# Delete the old avatar if it exists
|
||||
self.delete_old_avatar(user=user)
|
||||
avatar_asset = self.download_and_upload_avatar(avatar_url=avatar, user=user)
|
||||
if avatar_asset:
|
||||
user.avatar_asset = avatar_asset
|
||||
# If avatar upload fails, set the avatar to the original URL
|
||||
else:
|
||||
user.avatar = avatar
|
||||
|
||||
user.save()
|
||||
return user
|
||||
|
||||
def complete_login_or_signup(self):
|
||||
# Get email
|
||||
email = self.user_data.get("email")
|
||||
@@ -320,7 +255,6 @@ class Adapter:
|
||||
avatar_asset = self.download_and_upload_avatar(avatar_url=avatar, user=user)
|
||||
if avatar_asset:
|
||||
user.avatar_asset = avatar_asset
|
||||
user.avatar = avatar
|
||||
# If avatar upload fails, set the avatar to the original URL
|
||||
else:
|
||||
user.avatar = avatar
|
||||
@@ -328,10 +262,6 @@ class Adapter:
|
||||
# Create profile
|
||||
Profile.objects.create(user=user)
|
||||
|
||||
# Check if IDP sync is enabled and user is not signing up
|
||||
if self.check_sync_enabled() and not is_signup:
|
||||
user = self.sync_user_data(user=user)
|
||||
|
||||
# Save user data
|
||||
user = self.save_user_data(user=user)
|
||||
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import (
|
||||
ProjectMember,
|
||||
ProjectMemberInvite,
|
||||
@@ -9,8 +5,6 @@ from plane.db.models import (
|
||||
WorkspaceMemberInvite,
|
||||
)
|
||||
from plane.utils.cache import invalidate_cache_directly
|
||||
from plane.bgtasks.event_tracking_task import track_event
|
||||
from plane.utils.analytics_events import USER_JOINED_WORKSPACE
|
||||
|
||||
|
||||
def process_workspace_project_invitations(user):
|
||||
@@ -31,25 +25,15 @@ def process_workspace_project_invitations(user):
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
for workspace_member_invite in workspace_member_invites:
|
||||
[
|
||||
invalidate_cache_directly(
|
||||
path=f"/api/workspaces/{str(workspace_member_invite.workspace.slug)}/members/",
|
||||
url_params=False,
|
||||
user=False,
|
||||
multiple=True,
|
||||
)
|
||||
track_event.delay(
|
||||
user_id=user.id,
|
||||
event_name=USER_JOINED_WORKSPACE,
|
||||
slug=workspace_member_invite.workspace.slug,
|
||||
event_properties={
|
||||
"user_id": user.id,
|
||||
"workspace_id": workspace_member_invite.workspace.id,
|
||||
"workspace_slug": workspace_member_invite.workspace.slug,
|
||||
"role": workspace_member_invite.role,
|
||||
"joined_at": str(timezone.now().isoformat()),
|
||||
},
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from typing import Dict, Any
|
||||
|
||||
# third party imports
|
||||
from celery import shared_task
|
||||
@@ -10,11 +8,6 @@ from posthog import Posthog
|
||||
# module imports
|
||||
from plane.license.utils.instance_value import get_configuration_value
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.db.models import Workspace
|
||||
from plane.utils.analytics_events import USER_INVITED_TO_WORKSPACE, WORKSPACE_DELETED
|
||||
|
||||
|
||||
logger = logging.getLogger("plane.worker")
|
||||
|
||||
|
||||
def posthogConfiguration():
|
||||
@@ -24,10 +17,7 @@ def posthogConfiguration():
|
||||
"key": "POSTHOG_API_KEY",
|
||||
"default": os.environ.get("POSTHOG_API_KEY", None),
|
||||
},
|
||||
{
|
||||
"key": "POSTHOG_HOST",
|
||||
"default": os.environ.get("POSTHOG_HOST", None),
|
||||
},
|
||||
{"key": "POSTHOG_HOST", "default": os.environ.get("POSTHOG_HOST", None)},
|
||||
]
|
||||
)
|
||||
if POSTHOG_API_KEY and POSTHOG_HOST:
|
||||
@@ -36,42 +26,46 @@ def posthogConfiguration():
|
||||
return None, None
|
||||
|
||||
|
||||
def preprocess_data_properties(
|
||||
user_id: uuid.UUID, event_name: str, slug: str, data_properties: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
if event_name == USER_INVITED_TO_WORKSPACE or event_name == WORKSPACE_DELETED:
|
||||
try:
|
||||
# Check if the current user is the workspace owner
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
if str(workspace.owner_id) == str(user_id):
|
||||
data_properties["role"] = "owner"
|
||||
else:
|
||||
data_properties["role"] = "admin"
|
||||
except Workspace.DoesNotExist:
|
||||
logger.warning(f"Workspace {slug} does not exist while sending event {event_name} for user {user_id}")
|
||||
data_properties["role"] = "unknown"
|
||||
@shared_task
|
||||
def auth_events(user, email, user_agent, ip, event_name, medium, first_time):
|
||||
try:
|
||||
POSTHOG_API_KEY, POSTHOG_HOST = posthogConfiguration()
|
||||
|
||||
return data_properties
|
||||
if POSTHOG_API_KEY and POSTHOG_HOST:
|
||||
posthog = Posthog(POSTHOG_API_KEY, host=POSTHOG_HOST)
|
||||
posthog.capture(
|
||||
email,
|
||||
event=event_name,
|
||||
properties={
|
||||
"event_id": uuid.uuid4().hex,
|
||||
"user": {"email": email, "id": str(user)},
|
||||
"device_ctx": {"ip": ip, "user_agent": user_agent},
|
||||
"medium": medium,
|
||||
"first_time": first_time,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
|
||||
@shared_task
|
||||
def track_event(user_id: uuid.UUID, event_name: str, slug: str, event_properties: Dict[str, Any]):
|
||||
POSTHOG_API_KEY, POSTHOG_HOST = posthogConfiguration()
|
||||
|
||||
if not (POSTHOG_API_KEY and POSTHOG_HOST):
|
||||
logger.warning("Event tracking is not configured")
|
||||
return
|
||||
|
||||
def workspace_invite_event(user, email, user_agent, ip, event_name, accepted_from):
|
||||
try:
|
||||
# preprocess the data properties for massaging the payload
|
||||
# in the correct format for posthog
|
||||
data_properties = preprocess_data_properties(user_id, event_name, slug, event_properties)
|
||||
groups = {
|
||||
"workspace": slug,
|
||||
}
|
||||
# track the event using posthog
|
||||
posthog = Posthog(POSTHOG_API_KEY, host=POSTHOG_HOST)
|
||||
posthog.capture(distinct_id=str(user_id), event=event_name, properties=data_properties, groups=groups)
|
||||
POSTHOG_API_KEY, POSTHOG_HOST = posthogConfiguration()
|
||||
|
||||
if POSTHOG_API_KEY and POSTHOG_HOST:
|
||||
posthog = Posthog(POSTHOG_API_KEY, host=POSTHOG_HOST)
|
||||
posthog.capture(
|
||||
email,
|
||||
event=event_name,
|
||||
properties={
|
||||
"event_id": uuid.uuid4().hex,
|
||||
"user": {"email": email, "id": str(user)},
|
||||
"device_ctx": {"ip": ip, "user_agent": user_agent},
|
||||
"accepted_from": accepted_from,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return False
|
||||
return
|
||||
|
||||
@@ -116,11 +116,6 @@ class Project(BaseModel):
|
||||
external_source = models.CharField(max_length=255, null=True, blank=True)
|
||||
external_id = models.CharField(max_length=255, blank=True, null=True)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Track if timezone is provided, if so, don't override it with the workspace timezone when saving
|
||||
self.is_timezone_provided = kwargs.get("timezone") is not None
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def cover_image_url(self):
|
||||
# Return cover image url
|
||||
@@ -160,15 +155,7 @@ class Project(BaseModel):
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
from plane.db.models import Workspace
|
||||
|
||||
self.identifier = self.identifier.strip().upper()
|
||||
is_creating = self._state.adding
|
||||
|
||||
if is_creating and not self.is_timezone_provided:
|
||||
workspace = Workspace.objects.get(id=self.workspace_id)
|
||||
self.timezone = workspace.timezone
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
|
||||
@@ -172,16 +172,6 @@ class User(AbstractBaseUser, PermissionsMixin):
|
||||
|
||||
super(User, self).save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def get_display_name(cls, email):
|
||||
if not email:
|
||||
return "".join(random.choice(string.ascii_letters) for _ in range(6))
|
||||
return (
|
||||
email.split("@")[0]
|
||||
if len(email.split("@")) == 2
|
||||
else "".join(random.choice(string.ascii_letters) for _ in range(6))
|
||||
)
|
||||
|
||||
|
||||
class Profile(TimeAuditModel):
|
||||
SUNDAY = 0
|
||||
|
||||
@@ -187,15 +187,3 @@ class S3Storage(S3Boto3Storage):
|
||||
except ClientError as e:
|
||||
log_exception(e)
|
||||
return False
|
||||
|
||||
def delete_files(self, object_names):
|
||||
"""Delete an S3 object"""
|
||||
try:
|
||||
self.s3_client.delete_objects(
|
||||
Bucket=self.aws_storage_bucket_name,
|
||||
Delete={"Objects": [{"Key": object_name} for object_name in object_names]},
|
||||
)
|
||||
return True
|
||||
except ClientError as e:
|
||||
log_exception(e)
|
||||
return False
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
USER_JOINED_WORKSPACE = "user_joined_workspace"
|
||||
USER_INVITED_TO_WORKSPACE = "user_invited_to_workspace"
|
||||
WORKSPACE_CREATED = "workspace_created"
|
||||
WORKSPACE_DELETED = "workspace_deleted"
|
||||
@@ -44,12 +44,6 @@ google_config_variables = [
|
||||
"category": "GOOGLE",
|
||||
"is_encrypted": True,
|
||||
},
|
||||
{
|
||||
"key": "ENABLE_GOOGLE_SYNC",
|
||||
"value": os.environ.get("ENABLE_GOOGLE_SYNC", "0"),
|
||||
"category": "GOOGLE",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
]
|
||||
|
||||
github_config_variables = [
|
||||
@@ -71,12 +65,6 @@ github_config_variables = [
|
||||
"category": "GITHUB",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "ENABLE_GITHUB_SYNC",
|
||||
"value": os.environ.get("ENABLE_GITHUB_SYNC", "0"),
|
||||
"category": "GITHUB",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -99,12 +87,6 @@ gitlab_config_variables = [
|
||||
"category": "GITLAB",
|
||||
"is_encrypted": True,
|
||||
},
|
||||
{
|
||||
"key": "ENABLE_GITLAB_SYNC",
|
||||
"value": os.environ.get("ENABLE_GITLAB_SYNC", "0"),
|
||||
"category": "GITLAB",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
]
|
||||
|
||||
gitea_config_variables = [
|
||||
@@ -132,12 +114,6 @@ gitea_config_variables = [
|
||||
"category": "GITEA",
|
||||
"is_encrypted": True,
|
||||
},
|
||||
{
|
||||
"key": "ENABLE_GITEA_SYNC",
|
||||
"value": os.environ.get("ENABLE_GITEA_SYNC", "0"),
|
||||
"category": "GITEA",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
]
|
||||
|
||||
smtp_config_variables = [
|
||||
|
||||
@@ -7,21 +7,21 @@ function ErrorPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-surface-1 grid h-screen place-items-center p-4">
|
||||
<div className="grid h-screen place-items-center p-4">
|
||||
<div className="space-y-8 text-center">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-16 font-semibold">Yikes! That doesn{"'"}t look good.</h3>
|
||||
<p className="mx-auto md:w-1/2 text-13 text-secondary">
|
||||
That crashed Plane, pun intended. No worries, though. Our engineers have been notified. If you have more
|
||||
details, please write to{" "}
|
||||
<a href="mailto:support@plane.so" className="text-accent-primary">
|
||||
<a href="mailto:support@plane.so" className="text-custom-primary">
|
||||
support@plane.so
|
||||
</a>{" "}
|
||||
or on our{" "}
|
||||
<a
|
||||
href="https://discord.com/invite/A92xrEGCge"
|
||||
target="_blank"
|
||||
className="text-accent-primary"
|
||||
className="text-custom-primary"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Discord
|
||||
|
||||
@@ -114,7 +114,7 @@ function IssuesLayout(props: Route.ComponentProps) {
|
||||
|
||||
if (!publishSettings && !error) {
|
||||
return (
|
||||
<div className="bg-surface-1 flex items-center justify-center h-screen w-full">
|
||||
<div className="flex items-center justify-center h-screen w-full">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,9 +3,9 @@ import SomethingWentWrongImage from "@/app/assets/something-went-wrong.svg?url";
|
||||
|
||||
function NotFound() {
|
||||
return (
|
||||
<div className="h-screen w-screen grid place-items-center bg-surface-1">
|
||||
<div className="h-screen w-screen grid place-items-center">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto size-32 md:size-52 grid place-items-center rounded-full">
|
||||
<div className="mx-auto size-32 md:size-52 grid place-items-center rounded-full bg-layer-1">
|
||||
<div className="size-16 md:size-32 grid place-items-center">
|
||||
<img src={SomethingWentWrongImage} alt="Something went wrong" width={128} height={128} />
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@ const HomePage = observer(function HomePage() {
|
||||
|
||||
if (isInitializing)
|
||||
return (
|
||||
<div className="bg-surface-1 flex h-screen min-h-[500px] w-full justify-center items-center">
|
||||
<div className="flex h-screen min-h-[500px] w-full justify-center items-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
@@ -37,7 +37,7 @@ const HomePage = observer(function HomePage() {
|
||||
if (currentUser && isAuthenticated) {
|
||||
if (nextPath && isValidNextPath(nextPath)) {
|
||||
return (
|
||||
<div className="bg-surface-1 flex h-screen min-h-[500px] w-full justify-center items-center">
|
||||
<div className="flex h-screen min-h-[500px] w-full justify-center items-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
+1
-13
@@ -13,11 +13,6 @@ import type { Route } from "./+types/root";
|
||||
// local imports
|
||||
import ErrorPage from "./error";
|
||||
import { AppProviders } from "./providers";
|
||||
// fonts
|
||||
import "@fontsource-variable/inter";
|
||||
import interVariableWoff2 from "@fontsource-variable/inter/files/inter-latin-wght-normal.woff2?url";
|
||||
import "@fontsource/material-symbols-rounded";
|
||||
import "@fontsource/ibm-plex-mono";
|
||||
|
||||
const APP_TITLE = "Plane Publish | Make your Plane boards public with one-click";
|
||||
const APP_DESCRIPTION = "Plane Publish is a customer feedback management tool built on top of plane.so";
|
||||
@@ -29,13 +24,6 @@ export const links: Route.LinksFunction = () => [
|
||||
{ rel: "shortcut icon", href: faviconIco },
|
||||
{ rel: "manifest", href: siteWebmanifest },
|
||||
{ rel: "stylesheet", href: globalStyles },
|
||||
{
|
||||
rel: "preload",
|
||||
href: interVariableWoff2,
|
||||
as: "font",
|
||||
type: "font/woff2",
|
||||
crossOrigin: "anonymous",
|
||||
},
|
||||
];
|
||||
|
||||
export const headers: Route.HeadersFunction = () => ({
|
||||
@@ -84,7 +72,7 @@ export default function Root() {
|
||||
|
||||
export function HydrateFallback() {
|
||||
return (
|
||||
<div className="bg-surface-1 relative flex h-screen w-full items-center justify-center">
|
||||
<div className="relative flex h-screen w-full items-center justify-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
// plane imports
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import { SitesAuthService } from "@plane/services";
|
||||
import type { IEmailCheckData } from "@plane/types";
|
||||
import { OAuthOptions } from "@plane/ui";
|
||||
// assets
|
||||
import GiteaLogo from "@/app/assets/logos/gitea-logo.svg?url";
|
||||
import GithubLightLogo from "@/app/assets/logos/github-black.png?url";
|
||||
import GithubDarkLogo from "@/app/assets/logos/github-dark.svg?url";
|
||||
import GitlabLogo from "@/app/assets/logos/gitlab-logo.svg?url";
|
||||
import GoogleLogo from "@/app/assets/logos/google-logo.svg?url";
|
||||
// helpers
|
||||
import type { TAuthErrorInfo } from "@/helpers/authentication.helper";
|
||||
import { EErrorAlertType, authErrorHandler, EAuthenticationErrorCodes } from "@/helpers/authentication.helper";
|
||||
// hooks
|
||||
import { useOAuthConfig } from "@/hooks/oauth";
|
||||
import { useInstance } from "@/hooks/store/use-instance";
|
||||
// types
|
||||
import { EAuthModes, EAuthSteps } from "@/types/auth";
|
||||
@@ -29,6 +36,7 @@ export const AuthRoot = observer(function AuthRoot() {
|
||||
const emailParam = searchParams.get("email") || undefined;
|
||||
const error_code = searchParams.get("error_code") || undefined;
|
||||
const nextPath = searchParams.get("next_path") || undefined;
|
||||
const next_path = searchParams.get("next_path");
|
||||
// states
|
||||
const [authMode, setAuthMode] = useState<EAuthModes>(EAuthModes.SIGN_UP);
|
||||
const [authStep, setAuthStep] = useState<EAuthSteps>(EAuthSteps.EMAIL);
|
||||
@@ -36,6 +44,7 @@ export const AuthRoot = observer(function AuthRoot() {
|
||||
const [errorInfo, setErrorInfo] = useState<TAuthErrorInfo | undefined>(undefined);
|
||||
const [isPasswordAutoset, setIsPasswordAutoset] = useState(true);
|
||||
// hooks
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { config } = useInstance();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -78,8 +87,13 @@ export const AuthRoot = observer(function AuthRoot() {
|
||||
const isSMTPConfigured = config?.is_smtp_configured || false;
|
||||
const isMagicLoginEnabled = config?.is_magic_login_enabled || false;
|
||||
const isEmailPasswordEnabled = config?.is_email_password_enabled || false;
|
||||
const oAuthActionText = authMode === EAuthModes.SIGN_UP ? "Sign up" : "Sign in";
|
||||
const { isOAuthEnabled, oAuthOptions } = useOAuthConfig(oAuthActionText);
|
||||
const isOAuthEnabled =
|
||||
(config &&
|
||||
(config?.is_google_enabled ||
|
||||
config?.is_github_enabled ||
|
||||
config?.is_gitlab_enabled ||
|
||||
config?.is_gitea_enabled)) ||
|
||||
false;
|
||||
|
||||
// submit handler- email verification
|
||||
const handleEmailVerification = async (data: IEmailCheckData) => {
|
||||
@@ -139,6 +153,54 @@ export const AuthRoot = observer(function AuthRoot() {
|
||||
});
|
||||
};
|
||||
|
||||
const content = authMode === EAuthModes.SIGN_UP ? "Sign up" : "Sign in";
|
||||
|
||||
const OAuthConfig = [
|
||||
{
|
||||
id: "google",
|
||||
text: `${content} with Google`,
|
||||
icon: <img src={GoogleLogo} height={18} width={18} alt="Google Logo" />,
|
||||
onClick: () => {
|
||||
window.location.assign(`${API_BASE_URL}/auth/google/${next_path ? `?next_path=${next_path}` : ``}`);
|
||||
},
|
||||
enabled: config?.is_google_enabled,
|
||||
},
|
||||
{
|
||||
id: "github",
|
||||
text: `${content} with GitHub`,
|
||||
icon: (
|
||||
<img
|
||||
src={resolvedTheme === "dark" ? GithubLightLogo : GithubDarkLogo}
|
||||
height={18}
|
||||
width={18}
|
||||
alt="GitHub Logo"
|
||||
/>
|
||||
),
|
||||
onClick: () => {
|
||||
window.location.assign(`${API_BASE_URL}/auth/github/${next_path ? `?next_path=${next_path}` : ``}`);
|
||||
},
|
||||
enabled: config?.is_github_enabled,
|
||||
},
|
||||
{
|
||||
id: "gitlab",
|
||||
text: `${content} with GitLab`,
|
||||
icon: <img src={GitlabLogo} height={18} width={18} alt="GitLab Logo" />,
|
||||
onClick: () => {
|
||||
window.location.assign(`${API_BASE_URL}/auth/gitlab/${next_path ? `?next_path=${next_path}` : ``}`);
|
||||
},
|
||||
enabled: config?.is_gitlab_enabled,
|
||||
},
|
||||
{
|
||||
id: "gitea",
|
||||
text: `${content} with Gitea`,
|
||||
icon: <img src={GiteaLogo} height={18} width={18} alt="Gitea Logo" />,
|
||||
onClick: () => {
|
||||
window.location.assign(`${API_BASE_URL}/auth/gitea/${next_path ? `?next_path=${next_path}` : ``}`);
|
||||
},
|
||||
enabled: config?.is_gitea_enabled,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-center items-center flex-grow w-full py-6 mt-10">
|
||||
<div className="relative flex flex-col gap-6 max-w-[22.5rem] w-full">
|
||||
@@ -146,7 +208,7 @@ export const AuthRoot = observer(function AuthRoot() {
|
||||
<AuthBanner bannerData={errorInfo} handleBannerData={(value) => setErrorInfo(value)} />
|
||||
)}
|
||||
<AuthHeader authMode={authMode} />
|
||||
{isOAuthEnabled && <OAuthOptions options={oAuthOptions} compact={authStep === EAuthSteps.PASSWORD} />}
|
||||
{isOAuthEnabled && <OAuthOptions options={OAuthConfig} compact={authStep === EAuthSteps.PASSWORD} />}
|
||||
|
||||
{authStep === EAuthSteps.EMAIL && <AuthEmailForm defaultEmail={email} onSubmit={handleEmailVerification} />}
|
||||
{authStep === EAuthSteps.UNIQUE_CODE && (
|
||||
|
||||
@@ -83,7 +83,7 @@ export const AuthEmailForm = observer(function AuthEmailForm(props: TAuthEmailFo
|
||||
}}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<XCircle className="h-10 w-11 px-3 stroke-placeholder hover:cursor-pointer text-11" />
|
||||
<XCircle className="h-10 w-11 px-3 stroke-custom-text-400 hover:cursor-pointer text-11" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -132,7 +132,7 @@ export const AuthPasswordForm = observer(function AuthPasswordForm(props: Props)
|
||||
/>
|
||||
{passwordFormData.email.length > 0 && (
|
||||
<XCircle
|
||||
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={handleEmailClear}
|
||||
/>
|
||||
)}
|
||||
@@ -158,12 +158,12 @@ export const AuthPasswordForm = observer(function AuthPasswordForm(props: Props)
|
||||
/>
|
||||
{showPassword?.password ? (
|
||||
<EyeOff
|
||||
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("password")}
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("password")}
|
||||
/>
|
||||
)}
|
||||
@@ -189,12 +189,12 @@ export const AuthPasswordForm = observer(function AuthPasswordForm(props: Props)
|
||||
/>
|
||||
{showPassword?.retypePassword ? (
|
||||
<EyeOff
|
||||
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("retypePassword")}
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("retypePassword")}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -96,7 +96,7 @@ export function AuthUniqueCodeForm(props: TAuthUniqueCodeForm) {
|
||||
/>
|
||||
{uniqueCodeFormData.email.length > 0 && (
|
||||
<XCircle
|
||||
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={handleEmailClear}
|
||||
/>
|
||||
)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user