[WEB-5798] refactor: web and admin auth related components and update admin designs (#8431)

* refactor: web and admin auth related components and update admin designs.

* fix: format
This commit is contained in:
Prateek Shourya 2025-12-24 16:31:52 +05:30 committed by GitHub
parent 777200db7b
commit 0c795e95ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
80 changed files with 1087 additions and 950 deletions

View file

@ -114,13 +114,13 @@ export function InstanceAIForm(props: IInstanceAIForm) {
</div>
</div>
<div className="flex flex-col gap-2 items-start">
<div className="flex flex-col gap-4 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-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 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>
If you have a preferred AI models vendor, please get in{" "}
<a className="underline font-medium" href="https://plane.so/contact">

View file

@ -1,10 +1,13 @@
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";
// components
// types
import type { Route } from "./+types/page";
// local
import { InstanceAIForm } from "./form";
const InstanceAIPage = observer(function InstanceAIPage(_props: Route.ComponentProps) {
@ -14,30 +17,25 @@ const InstanceAIPage = observer(function InstanceAIPage(_props: Route.ComponentP
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
return (
<>
<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.
<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>
</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>
</>
<Loader.Item height="50px" width="20%" />
</Loader>
)}
</PageWrapper>
);
});

View file

@ -196,7 +196,7 @@ export function InstanceGiteaConfigForm(props: Props) {
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 +205,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/60 rounded-lg">
<div className="flex flex-col gap-y-4 px-6 pt-1.5 pb-4 bg-layer-1 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} />

View file

@ -4,13 +4,16 @@ import useSWR from "swr";
// plane internal packages
import { setPromiseToast } from "@plane/propel/toast";
import { Loader, ToggleSwitch } from "@plane/ui";
// components
// assets
import giteaLogo from "@/app/assets/logos/gitea-logo.svg?url";
// components
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
import { PageWrapper } from "@/components/common/page-wrapper";
// hooks
import { useInstance } from "@/hooks/store";
//local components
// types
import type { Route } from "./+types/page";
// local
import { InstanceGiteaConfigForm } from "./form";
const InstanceGiteaAuthenticationPage = observer(function InstanceGiteaAuthenticationPage() {
@ -32,7 +35,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"}.`,
@ -56,42 +59,39 @@ const InstanceGiteaAuthenticationPage = observer(function InstanceGiteaAuthentic
const isGiteaEnabled = enableGiteaConfig === "1";
return (
<>
<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>
</>
<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>
);
});
export const meta: Route.MetaFunction = () => [{ title: "Gitea Authentication - God Mode" }];

View file

@ -217,7 +217,7 @@ export function InstanceGithubConfigForm(props: Props) {
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 +238,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-1/60 font-medium text-11 uppercase flex items-center gap-x-3 text-secondary">
<div className="px-6 py-3 bg-layer-3 font-medium text-11 uppercase flex items-center gap-x-3 text-secondary">
<Monitor className="w-3 h-3" />
Web
</div>

View file

@ -6,15 +6,17 @@ import useSWR from "swr";
import { setPromiseToast } from "@plane/propel/toast";
import { Loader, ToggleSwitch } from "@plane/ui";
import { resolveGeneralTheme } from "@plane/utils";
// components
// assets
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";
// icons
// local components
// types
import type { Route } from "./+types/page";
// local
import { InstanceGithubConfigForm } from "./form";
const InstanceGithubAuthenticationPage = observer(function InstanceGithubAuthenticationPage(
@ -41,7 +43,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"}.`,
@ -65,49 +67,46 @@ const InstanceGithubAuthenticationPage = observer(function InstanceGithubAuthent
const isGithubEnabled = enableGithubConfig === "1";
return (
<>
<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>
</>
<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>
);
});

View file

@ -200,7 +200,7 @@ export function InstanceGitlabConfigForm(props: Props) {
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 +209,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-1/60 rounded-lg">
<div className="flex flex-col gap-y-4 px-6 pt-1.5 pb-4 bg-layer-3 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} />

View file

@ -3,14 +3,16 @@ import { observer } from "mobx-react";
import useSWR from "swr";
import { setPromiseToast } from "@plane/propel/toast";
import { Loader, ToggleSwitch } from "@plane/ui";
// components
// assets
import GitlabLogo from "@/app/assets/logos/gitlab-logo.svg?url";
// components
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
import { PageWrapper } from "@/components/common/page-wrapper";
// hooks
import { useInstance } from "@/hooks/store";
// icons
// local components
// types
import type { Route } from "./+types/page";
// local
import { InstanceGitlabConfigForm } from "./form";
const InstanceGitlabAuthenticationPage = observer(function InstanceGitlabAuthenticationPage(
@ -35,7 +37,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"}.`,
@ -56,46 +58,43 @@ const InstanceGitlabAuthenticationPage = observer(function InstanceGitlabAuthent
});
};
return (
<>
<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>
</>
<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>
);
});

View file

@ -205,7 +205,7 @@ export function InstanceGoogleConfigForm(props: Props) {
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 +226,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-1/60 font-medium text-11 uppercase flex items-center gap-x-3 text-secondary">
<div className="px-6 py-3 bg-layer-3 font-medium text-11 uppercase flex items-center gap-x-3 text-secondary">
<Monitor className="w-3 h-3" />
Web
</div>

View file

@ -3,14 +3,16 @@ import { observer } from "mobx-react";
import useSWR from "swr";
import { setPromiseToast } from "@plane/propel/toast";
import { Loader, ToggleSwitch } from "@plane/ui";
// components
// assets
import GoogleLogo from "@/app/assets/logos/google-logo.svg?url";
// components
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
import { PageWrapper } from "@/components/common/page-wrapper";
// hooks
import { useInstance } from "@/hooks/store";
// icons
// local components
// types
import type { Route } from "./+types/page";
// local
import { InstanceGoogleConfigForm } from "./form";
const InstanceGoogleAuthenticationPage = observer(function InstanceGoogleAuthenticationPage(
@ -35,7 +37,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"}.`,
@ -56,47 +58,44 @@ const InstanceGoogleAuthenticationPage = observer(function InstanceGoogleAuthent
});
};
return (
<>
<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
<PageWrapper
customHeader={
<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}
/>
</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>
</>
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>
);
});

View file

@ -1,27 +1,33 @@
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 } from "@plane/utils";
import { cn, resolveGeneralTheme } from "@plane/utils";
// components
import { PageWrapper } from "@/components/common/page-wrapper";
// hooks
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
import { useAuthenticationModes } from "@/hooks/oauth";
import { useInstance } from "@/hooks/store";
// plane admin components
import { AuthenticationModes } from "@/plane-admin/components/authentication";
// types
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);
@ -54,59 +60,65 @@ const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(
});
};
const authenticationModes = useAuthenticationModes({ disabled: isSubmitting, updateConfig, resolvedTheme });
return (
<>
<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>
<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>
</div>
<div className="text-16 font-medium pt-6">Available authentication modes</div>
<AuthenticationModes disabled={isSubmitting} updateConfig={updateConfig} />
</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>
)}
<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}
/>
))}
</div>
</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>
);
});

View file

@ -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"

View file

@ -3,10 +3,13 @@ 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";
// components
// types
import type { Route } from "./+types/page";
// local
import { InstanceEmailForm } from "./email-config-form";
const InstanceEmailPage = observer(function InstanceEmailPage(_props: Route.ComponentProps) {
@ -49,44 +52,43 @@ const InstanceEmailPage = observer(function InstanceEmailPage(_props: Route.Comp
}, [formattedConfig]);
return (
<>
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="flex items-center justify-between gap-4 border-b border-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>
<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="text-13 font-regular text-tertiary">
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.&nbsp;
<span className="text-danger">Misconfigs can lead to email bounces and errors.</span>
</div>
Set it up below and please test your settings before you save them.&nbsp;
<span className="text-danger">Misconfigs can lead to email bounces and errors.</span>
</div>
</div>
{isLoading ? (
<Loader>
<Loader.Item width="24px" height="16px" className="rounded-full" />
</Loader>
</>
),
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} />
) : (
<ToggleSwitch value={isSMTPEnabled} onChange={handleToggle} size="sm" disabled={isSubmitting} />
<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>
{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>
</>
</>
)}
</PageWrapper>
);
});

View file

@ -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>

View file

@ -1,17 +1,17 @@
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
import { Telescope } from "lucide-react";
// types
// plane imports
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";
import { useInstance } from "@/hooks/store";
import { IntercomConfig } from "./intercom";
// hooks
import { useInstance } from "@/hooks/store";
// components
import { IntercomConfig } from "./intercom";
export interface IGeneralConfigurationForm {
instance: IInstance;
@ -27,8 +27,8 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
const {
handleSubmit,
control,
watch,
formState: { errors, isSubmitting },
watch,
} = 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-4">
<div className="text-16 font-medium text-primary">Chat + telemetry</div>
<div className="space-y-6">
<div className="text-16 font-medium text-primary pb-1.5 border-b border-subtle">Chat + telemetry</div>
<IntercomConfig isTelemetryEnabled={watch("is_telemetry_enabled") ?? false} />
<div className="flex items-center gap-14 px-4 py-3 border border-subtle rounded-sm">
<div className="flex items-center gap-14">
<div className="grow flex items-center gap-4">
<div className="shrink-0">
<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 className="flex items-center justify-center size-11 bg-layer-1 rounded-lg">
<Telescope className="size-5 text-tertiary" />
</div>
</div>
<div className="grow">
@ -144,8 +144,15 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
</div>
<div>
<Button variant="primary" size="lg" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
{isSubmitting ? "Saving..." : "Save changes"}
<Button
variant="primary"
size="lg"
onClick={() => {
void handleSubmit(onSubmit)();
}}
loading={isSubmitting}
>
{isSubmitting ? "Saving" : "Save changes"}
</Button>
</div>
</div>

View file

@ -44,16 +44,16 @@ export const IntercomConfig = observer(function IntercomConfig(props: TIntercomC
};
const enableIntercomConfig = () => {
submitInstanceConfigurations({ IS_INTERCOM_ENABLED: isIntercomEnabled ? "0" : "1" });
void submitInstanceConfigurations({ IS_INTERCOM_ENABLED: isIntercomEnabled ? "0" : "1" });
};
return (
<>
<div className="flex items-center gap-14 px-4 py-3 border border-subtle rounded-sm">
<div className="flex items-center gap-14">
<div className="grow flex items-center gap-4">
<div className="shrink-0">
<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 className="flex items-center justify-center size-11 bg-layer-1 rounded-lg">
<MessageSquare className="size-5 text-tertiary p-0.5" />
</div>
</div>

View file

@ -1,30 +1,26 @@
import { observer } from "mobx-react";
// components
import { PageWrapper } from "@/components/common/page-wrapper";
// hooks
import { useInstance } from "@/hooks/store";
// components
import type { Route } from "./+types/page";
// local imports
import { GeneralConfigurationForm } from "./form";
// types
import type { Route } from "./+types/page";
function GeneralPage() {
const { instance, instanceAdmins } = useInstance();
return (
<>
<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>
</>
<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>
);
}

View file

@ -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>

View file

@ -1,10 +1,13 @@
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";
// local
// types
import type { Route } from "./+types/page";
// local
import { InstanceImageConfigForm } from "./form";
const InstanceImagePage = observer(function InstanceImagePage(_props: Route.ComponentProps) {
@ -14,26 +17,21 @@ const InstanceImagePage = observer(function InstanceImagePage(_props: Route.Comp
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
return (
<>
<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>
</>
<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>
);
});

View file

@ -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) {

View file

@ -71,14 +71,14 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
useEffect(() => {
if (csrfToken === undefined)
authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
void 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-3.5">
<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="h-full w-full truncate">
<div
className={`flex flex-grow items-center gap-x-2 truncate rounded-sm py-1 ${
className={`flex flex-grow items-center gap-x-2 truncate rounded-sm ${
isSidebarCollapsed ? "justify-center" : ""
}`}
>
@ -88,8 +88,8 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
"cursor-default": !isSidebarCollapsed,
})}
>
<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 className="flex size-8 flex-shrink-0 items-center justify-center rounded-sm bg-layer-1">
<UserCog2 className="size-5 text-primary" />
</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-14 font-medium text-secondary">Instance admin</h4>
<h4 className="grow truncate text-body-md-medium text-primary">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-14"
className="!text-body-sm-medium"
/>
</Menu.Button>

View file

@ -9,11 +9,9 @@ import { DiscordIcon, GithubIcon, PageIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { cn } from "@plane/utils";
// hooks
import { useTheme } from "@/hooks/store";
import { useInstance, useTheme } from "@/hooks/store";
// assets
import packageJson from "package.json";
const helpOptions = [
{
name: "Documentation",
@ -36,6 +34,7 @@ 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);
@ -55,9 +54,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.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`}
className={`relative px-2 py-1 flex items-center gap-1 rounded-sm bg-layer-1 text-body-xs-medium text-secondary whitespace-nowrap`}
>
<ExternalLink size={14} />
<ExternalLink size={16} />
{!isSidebarCollapsed && "Redirect to Plane"}
</a>
</Tooltip>
@ -69,7 +68,7 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
}`}
onClick={() => setIsNeedHelpOpen((prev) => !prev)}
>
<HelpCircle className="h-3.5 w-3.5" />
<HelpCircle className="size-4" />
</button>
</Tooltip>
<Tooltip tooltipContent="Toggle sidebar" position={isSidebarCollapsed ? "right" : "top"} className="ml-4">
@ -80,7 +79,7 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
}`}
onClick={() => toggleSidebar(!isSidebarCollapsed)}
>
<MoveLeft className={`h-3.5 w-3.5 duration-300 ${isSidebarCollapsed ? "rotate-180" : ""}`} />
<MoveLeft className={`size-4 duration-300 ${isSidebarCollapsed ? "rotate-180" : ""}`} />
</button>
</Tooltip>
</div>
@ -108,7 +107,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" width={14} height={14} />
<Icon className="h-3.5 w-3.5 text-secondary" />
</div>
<span className="text-11">{name}</span>
</div>
@ -129,7 +128,7 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
);
})}
</div>
<div className="px-2 pb-1 pt-2 text-10">Version: v{packageJson.version}</div>
<div className="px-2 pb-1 pt-2 text-10">Version: v{instance?.current_version}</div>
</div>
</Transition>
</div>

View file

@ -1,58 +1,20 @@
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";
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/`,
},
];
import { useSidebarMenu } from "@/hooks/use-sidebar-menu";
export const AdminSidebarMenu = observer(function AdminSidebarMenu() {
// store hooks
const { isSidebarCollapsed, toggleSidebar } = useTheme();
// router
const pathName = usePathname();
// store hooks
const { isSidebarCollapsed, toggleSidebar } = useTheme();
// derived values
const sidebarMenu = useSidebarMenu();
const handleItemClick = () => {
if (window.innerWidth < 768) {
@ -62,40 +24,27 @@ 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">
{INSTANCE_ADMIN_LINKS.map((item, index) => {
const isActive = item.href === pathName || pathName.includes(item.href);
{sidebarMenu.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`,
isActive
? "bg-accent-primary/10 text-accent-primary"
: "text-secondary hover:bg-layer-1-hover focus:bg-layer-1-hover",
"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,
},
isSidebarCollapsed ? "justify-center" : "w-[260px]"
)}
>
{<item.Icon className="h-4 w-4 flex-shrink-0" />}
{!isSidebarCollapsed && (
<div className="w-full ">
<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 className={cn(`text-body-xs-medium transition-colors`)}>{item.name}</div>
<div className={cn(`text-caption-sm-regular transition-colors`)}>{item.description}</div>
</div>
)}
</div>

View file

@ -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 (
<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>
<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>
);
});

View file

@ -8,12 +8,13 @@ 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) {
@ -68,99 +69,95 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
};
return (
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="flex items-center justify-between gap-4 border-b border-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>
<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}
/>
<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>
) : (
<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&apos;t yet delete workspaces and you can only go to the workspace if you are an Admin or a
Member.
</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="flex items-center gap-2">
<Link href="/workspace/create" className={getButtonStyling("primary", "lg")}>
Create workspace
</Link>
<div className={cn("font-regular leading-5 text-tertiary text-11")}>
You can&apos;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 flex-col gap-4 py-2">
{workspaceIds.map((workspaceId) => (
<WorkspaceListItem key={workspaceId} workspaceId={workspaceId} />
))}
<div className="flex items-center gap-2">
<Link href="/workspace/create" className={getButtonStyling("primary", "base")}>
Create workspace
</Link>
</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 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>
</PageWrapper>
);
});

View file

@ -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/80"
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"
onClick={() => handleBannerData && handleBannerData(undefined)}
>
<CloseIcon className="w-4 h-4 flex-shrink-0" />

View file

@ -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/80" />,
icon: <Mails className="h-6 w-6 p-0.5 text-tertiary" />,
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/80" />,
icon: <KeyRound className="h-6 w-6 p-0.5 text-tertiary" />,
config: <PasswordLoginConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
{

View file

@ -1,15 +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";
import { AppProviders } from "./providers";
// fonts
import "@fontsource-variable/inter";
import interVariableWoff2 from "@fontsource-variable/inter/files/inter-latin-wght-normal.woff2?url";

View file

@ -1 +0,0 @@
export * from "./authentication-modes";

View file

@ -1 +0,0 @@
export * from "./upgrade-button";

View file

@ -1,20 +0,0 @@
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>
);
}

View file

@ -16,7 +16,7 @@ export function AuthenticationMethodCard(props: Props) {
return (
<div
className={cn("w-full flex items-center gap-14 rounded-sm", {
className={cn("w-full flex items-center gap-14 rounded-lg bg-layer-2", {
"px-4 py-3 border border-subtle": withBorder,
})}
>

View file

@ -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/80" />
<Settings2 className="h-4 w-4 p-0.5 text-tertiary" />
Configure
</Link>
)}

View file

@ -1,4 +1,3 @@
import React from "react";
import { observer } from "mobx-react";
import Link from "next/link";
// icons
@ -43,7 +42,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/80" />
<Settings2 className="h-4 w-4 p-0.5 text-tertiary" />
Configure
</Link>
)}

View file

@ -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/80" />
<Settings2 className="h-4 w-4 p-0.5 text-tertiary" />
Configure
</Link>
)}

View file

@ -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/80" />
<Settings2 className="h-4 w-4 p-0.5 text-tertiary" />
Configure
</Link>
)}

View file

@ -0,0 +1,13 @@
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",
};

View file

@ -0,0 +1 @@
export const EXTENDED_HEADER_SEGMENT_LABELS: Record<string, string> = {};

View file

@ -7,51 +7,30 @@ 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 (
<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"
<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"
onClick={() => toggleSidebar(!isSidebarCollapsed)}
>
<Menu size={14} className="text-secondary group-hover:text-primary transition-all" />
</div>
</button>
);
});
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.
@ -61,14 +40,14 @@ export const AdminHeader = observer(function AdminHeader() {
const breadcrumbItems = pathSegments.map((segment) => {
currentUrl += "/" + segment;
return {
title: getHeaderTitle(segment),
title: HEADER_SEGMENT_LABELS[segment] ?? segment.toUpperCase(),
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">

View file

@ -0,0 +1,44 @@
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>
);
};

View file

@ -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}

View file

@ -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-4 gap-2.5 truncate border border-subtle/70 hover:border-subtle bg-layer-1 hover:bg-layer-1-hover rounded-md"
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"
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-sm bg-accent-primary text-on-color"
!workspace?.logo_url && "rounded-lg 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={14} className="text-placeholder group-hover:text-secondary" />
<ExternalLink size={16} className="text-placeholder group-hover:text-secondary" />
</div>
</a>
);

View file

@ -1,72 +1,61 @@
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";
import { resolveGeneralTheme } from "@plane/utils";
// components
// assets
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";
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 gitlabLogo from "@/app/assets/logos/gitlab-logo.svg?url";
import googleLogo from "@/app/assets/logos/google-logo.svg?url";
// components
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 getAuthenticationModes: (props: TGetBaseAuthenticationModeProps) => TInstanceAuthenticationModes[] = ({
export const getCoreAuthenticationModesMap: (
props: TGetBaseAuthenticationModeProps
) => Record<TCoreInstanceAuthenticationModeKeys, 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/80" />,
icon: <Mails className="h-6 w-6 p-0.5 text-tertiary" />,
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/80" />,
icon: <KeyRound className="h-6 w-6 p-0.5 text-tertiary" />,
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={resolveGeneralTheme(resolvedTheme) === "dark" ? githubDarkModeImage : githubLightModeImage}
src={resolvedTheme === "dark" ? githubDarkModeImage : githubLightModeImage}
height={20}
width={20}
alt="GitHub Logo"
@ -74,56 +63,18 @@ export const getAuthenticationModes: (props: TGetBaseAuthenticationModeProps) =>
),
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>
);
});

View file

@ -0,0 +1,19 @@
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;
};

View file

@ -0,0 +1,7 @@
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
export type TGetAuthenticationModeProps = {
disabled: boolean;
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
resolvedTheme: string | undefined;
};

View file

@ -1,6 +1,6 @@
import { useContext } from "react";
// store
import { StoreContext } from "@/app/(all)/store.provider";
import { StoreContext } from "@/providers/store.provider";
import type { IInstanceStore } from "@/store/instance.store";
export const useInstance = (): IInstanceStore => {

View file

@ -1,6 +1,6 @@
import { useContext } from "react";
// store
import { StoreContext } from "@/app/(all)/store.provider";
import { StoreContext } from "@/providers/store.provider";
import type { IThemeStore } from "@/store/theme.store";
export const useTheme = (): IThemeStore => {

View file

@ -1,6 +1,6 @@
import { useContext } from "react";
// store
import { StoreContext } from "@/app/(all)/store.provider";
import { StoreContext } from "@/providers/store.provider";
import type { IUserStore } from "@/store/user.store";
export const useUser = (): IUserStore => {

View file

@ -1,6 +1,6 @@
import { useContext } from "react";
// store
import { StoreContext } from "@/app/(all)/store.provider";
import { StoreContext } from "@/providers/store.provider";
import type { IWorkspaceStore } from "@/store/workspace.store";
export const useWorkspace = (): IWorkspaceStore => {

View file

@ -0,0 +1,46 @@
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/`,
},
};

View file

@ -0,0 +1,14 @@
// 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,
];
}

View file

@ -0,0 +1,8 @@
import type { LucideIcon } from "lucide-react";
export type TSidebarMenuItem = {
Icon: LucideIcon | React.ComponentType<{ className?: string }>;
name: string;
description: string;
href: string;
};

View file

@ -1,10 +1,11 @@
import { ThemeProvider } from "next-themes";
import { SWRConfig } from "swr";
import { AppProgressBar } from "@/lib/b-progress";
import { InstanceProvider } from "./(all)/instance.provider";
import { StoreProvider } from "./(all)/store.provider";
import { ToastWithTheme } from "./(all)/toast";
import { UserProvider } from "./(all)/user.provider";
// local imports
import { ToastWithTheme } from "./toast";
import { StoreProvider } from "./store.provider";
import { InstanceProvider } from "./instance.provider";
import { UserProvider } from "./user.provider";
const DEFAULT_SWR_CONFIG = {
refreshWhenHidden: false,
@ -15,7 +16,7 @@ const DEFAULT_SWR_CONFIG = {
errorRetryCount: 3,
};
export function AppProviders({ children }: { children: React.ReactNode }) {
export function CoreProviders({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider themes={["light", "dark"]} defaultTheme="system" enableSystem>
<AppProgressBar />

View file

@ -0,0 +1,3 @@
export function ExtendedProviders({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View file

@ -0,0 +1,10 @@
import { CoreProviders } from "./core";
import { ExtendedProviders } from "./extended";
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<CoreProviders>
<ExtendedProviders>{children}</ExtendedProviders>
</CoreProviders>
);
}

View file

@ -1 +0,0 @@
export * from "ce/components/authentication/authentication-modes";

View file

@ -1 +0,0 @@
export * from "./authentication-modes";

View file

@ -1 +0,0 @@
export * from "ce/components/common";

View file

@ -1,22 +1,15 @@
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";
@ -36,7 +29,6 @@ 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);
@ -44,7 +36,6 @@ 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(() => {
@ -87,13 +78,8 @@ 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 isOAuthEnabled =
(config &&
(config?.is_google_enabled ||
config?.is_github_enabled ||
config?.is_gitlab_enabled ||
config?.is_gitea_enabled)) ||
false;
const oAuthActionText = authMode === EAuthModes.SIGN_UP ? "Sign up" : "Sign in";
const { isOAuthEnabled, oAuthOptions } = useOAuthConfig(oAuthActionText);
// submit handler- email verification
const handleEmailVerification = async (data: IEmailCheckData) => {
@ -153,54 +139,6 @@ 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">
@ -208,7 +146,7 @@ export const AuthRoot = observer(function AuthRoot() {
<AuthBanner bannerData={errorInfo} handleBannerData={(value) => setErrorInfo(value)} />
)}
<AuthHeader authMode={authMode} />
{isOAuthEnabled && <OAuthOptions options={OAuthConfig} compact={authStep === EAuthSteps.PASSWORD} />}
{isOAuthEnabled && <OAuthOptions options={oAuthOptions} compact={authStep === EAuthSteps.PASSWORD} />}
{authStep === EAuthSteps.EMAIL && <AuthEmailForm defaultEmail={email} onSubmit={handleEmailVerification} />}
{authStep === EAuthSteps.UNIQUE_CODE && (

View file

@ -0,0 +1,82 @@
// plane imports
import { useSearchParams } from "next/navigation";
import { useTheme } from "next-themes";
import { API_BASE_URL } from "@plane/constants";
import type { TOAuthConfigs, TOAuthOption } from "@plane/types";
// 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";
// hooks
import { useInstance } from "@/hooks/store/use-instance";
export const useCoreOAuthConfig = (oauthActionText: string): TOAuthConfigs => {
//router
const searchParams = useSearchParams();
// query params
const next_path = searchParams.get("next_path");
// theme
const { resolvedTheme } = useTheme();
// store hooks
const { config } = useInstance();
// derived values
const isOAuthEnabled =
(config &&
(config?.is_google_enabled ||
config?.is_github_enabled ||
config?.is_gitlab_enabled ||
config?.is_gitea_enabled)) ||
false;
const oAuthOptions: TOAuthOption[] = [
{
id: "google",
text: `${oauthActionText} 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: `${oauthActionText} 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: `${oauthActionText} 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: `${oauthActionText} 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 {
isOAuthEnabled,
oAuthOptions,
};
};

View file

@ -0,0 +1,7 @@
// plane imports
import type { TOAuthConfigs } from "@plane/types";
export const useExtendedOAuthConfig = (_oauthActionText: string): TOAuthConfigs => ({
isOAuthEnabled: false,
oAuthOptions: [],
});

View file

@ -0,0 +1,14 @@
// plane imports
import type { TOAuthConfigs } from "@plane/types";
// local imports
import { useCoreOAuthConfig } from "./core";
import { useExtendedOAuthConfig } from "./extended";
export const useOAuthConfig = (oauthActionText: string = "Continue"): TOAuthConfigs => {
const coreOAuthConfig = useCoreOAuthConfig(oauthActionText);
const extendedOAuthConfig = useExtendedOAuthConfig(oauthActionText);
return {
isOAuthEnabled: coreOAuthConfig.isOAuthEnabled || extendedOAuthConfig.isOAuthEnabled,
oAuthOptions: [...coreOAuthConfig.oAuthOptions, ...extendedOAuthConfig.oAuthOptions],
};
};

View file

@ -2,75 +2,7 @@ import { route } from "@react-router/dev/routes";
import type { RouteConfigEntry } from "@react-router/dev/routes";
import { coreRoutes } from "./routes/core";
import { extendedRoutes } from "./routes/extended";
/**
* Merges two route configurations intelligently.
* - Deep merges children when the same layout file exists in both arrays
* - Deduplicates routes by file property, preferring extended over core
* - Maintains order: core routes first, then extended routes at each level
*/
function mergeRoutes(core: RouteConfigEntry[], extended: RouteConfigEntry[]): RouteConfigEntry[] {
// Step 1: Create a Map to track routes by file path
const routeMap = new Map<string, RouteConfigEntry>();
// Step 2: Process core routes first
for (const coreRoute of core) {
const fileKey = coreRoute.file;
routeMap.set(fileKey, coreRoute);
}
// Step 3: Process extended routes
for (const extendedRoute of extended) {
const fileKey = extendedRoute.file;
if (routeMap.has(fileKey)) {
// Route exists in both - need to merge
const coreRoute = routeMap.get(fileKey)!;
// Check if both have children (layouts that need deep merging)
if (coreRoute.children && extendedRoute.children) {
// Deep merge: recursively merge children
const mergedChildren = mergeRoutes(
Array.isArray(coreRoute.children) ? coreRoute.children : [],
Array.isArray(extendedRoute.children) ? extendedRoute.children : []
);
routeMap.set(fileKey, {
...extendedRoute,
children: mergedChildren,
});
} else {
// No children or only one has children - prefer extended
routeMap.set(fileKey, extendedRoute);
}
} else {
// Route only exists in extended
routeMap.set(fileKey, extendedRoute);
}
}
// Step 4: Build final array maintaining order (core first, then extended-only)
const result: RouteConfigEntry[] = [];
// Add all core routes (now merged or original)
for (const coreRoute of core) {
const fileKey = coreRoute.file;
if (routeMap.has(fileKey)) {
result.push(routeMap.get(fileKey)!);
routeMap.delete(fileKey); // Remove so we don't add it again
}
}
// Add remaining extended-only routes
for (const extendedRoute of extended) {
const fileKey = extendedRoute.file;
if (routeMap.has(fileKey)) {
result.push(routeMap.get(fileKey)!);
routeMap.delete(fileKey);
}
}
return result;
}
import { mergeRoutes } from "./routes/helper";
/**
* Main Routes Configuration

View file

@ -0,0 +1,70 @@
import type { RouteConfigEntry } from "@react-router/dev/routes";
/**
* Merges two route configurations intelligently.
* - Deep merges children when the same layout file exists in both arrays
* - Deduplicates routes by file property, preferring extended over core
* - Maintains order: core routes first, then extended routes at each level
*/
export function mergeRoutes(core: RouteConfigEntry[], extended: RouteConfigEntry[]): RouteConfigEntry[] {
// Step 1: Create a Map to track routes by file path
const routeMap = new Map<string, RouteConfigEntry>();
// Step 2: Process core routes first
for (const coreRoute of core) {
const fileKey = coreRoute.file;
routeMap.set(fileKey, coreRoute);
}
// Step 3: Process extended routes
for (const extendedRoute of extended) {
const fileKey = extendedRoute.file;
if (routeMap.has(fileKey)) {
// Route exists in both - need to merge
const coreRoute = routeMap.get(fileKey)!;
// Check if both have children (layouts that need deep merging)
if (coreRoute.children && extendedRoute.children) {
// Deep merge: recursively merge children
const mergedChildren = mergeRoutes(
Array.isArray(coreRoute.children) ? coreRoute.children : [],
Array.isArray(extendedRoute.children) ? extendedRoute.children : []
);
routeMap.set(fileKey, {
...extendedRoute,
children: mergedChildren,
});
} else {
// No children or only one has children - prefer extended
routeMap.set(fileKey, extendedRoute);
}
} else {
// Route only exists in extended
routeMap.set(fileKey, extendedRoute);
}
}
// Step 4: Build final array maintaining order (core first, then extended-only)
const result: RouteConfigEntry[] = [];
// Add all core routes (now merged or original)
for (const coreRoute of core) {
const fileKey = coreRoute.file;
if (routeMap.has(fileKey)) {
result.push(routeMap.get(fileKey)!);
routeMap.delete(fileKey); // Remove so we don't add it again
}
}
// Add remaining extended-only routes
for (const extendedRoute of extended) {
const fileKey = extendedRoute.file;
if (routeMap.has(fileKey)) {
result.push(routeMap.get(fileKey)!);
routeMap.delete(fileKey);
}
}
return result;
}

View file

@ -1,77 +0,0 @@
// plane imports
import { API_BASE_URL } from "@plane/constants";
import type { TOAuthOption } from "@plane/ui";
// assets
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 GiteaLogo from "@/app/assets/logos/gitea-logo.svg?url";
import GoogleLogo from "@/app/assets/logos/google-logo.svg?url";
import type { IInstanceConfig } from "@plane/types";
export type OAuthConfigParams = {
OauthButtonContent: "Sign up" | "Sign in";
next_path: string | null;
config: IInstanceConfig | undefined;
resolvedTheme: string | undefined;
};
export const isOAuthEnabled = (config: IInstanceConfig | undefined) =>
(config &&
(config?.is_google_enabled ||
config?.is_github_enabled ||
config?.is_gitlab_enabled ||
config?.is_gitea_enabled)) ||
false;
export function OAUTH_CONFIG({
OauthButtonContent,
next_path,
config,
resolvedTheme,
}: OAuthConfigParams): TOAuthOption[] {
return [
{
id: "google",
text: `${OauthButtonContent} with Google`,
icon: <img src={GoogleLogo} className="h-4 w-4 object-contain" alt="Google Logo" />,
onClick: () => {
window.location.assign(`${API_BASE_URL}/auth/google/${next_path ? `?next_path=${next_path}` : ``}`);
},
enabled: config?.is_google_enabled || false,
},
{
id: "github",
text: `${OauthButtonContent} with GitHub`,
icon: (
<img
src={resolvedTheme === "dark" ? GithubDarkLogo : GithubLightLogo}
className="h-4 w-4 object-contain"
alt="GitHub Logo"
/>
),
onClick: () => {
window.location.assign(`${API_BASE_URL}/auth/github/${next_path ? `?next_path=${next_path}` : ``}`);
},
enabled: config?.is_github_enabled || false,
},
{
id: "gitlab",
text: `${OauthButtonContent} with GitLab`,
icon: <img src={GitlabLogo} className="h-4 w-4 object-contain" alt="GitLab Logo" />,
onClick: () => {
window.location.assign(`${API_BASE_URL}/auth/gitlab/${next_path ? `?next_path=${next_path}` : ``}`);
},
enabled: config?.is_gitlab_enabled || false,
},
{
id: "gitea",
text: `${OauthButtonContent} with Gitea`,
icon: <img src={GiteaLogo} className="h-4 w-4 object-contain" alt="Gitea Logo" />,
onClick: () => {
window.location.assign(`${API_BASE_URL}/auth/gitea/${next_path ? `?next_path=${next_path}` : ``}`);
},
enabled: config?.is_gitea_enabled || false,
},
];
}

View file

@ -1,23 +1,21 @@
import type { FC } from "react";
import { Info } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { CloseIcon } from "@plane/propel/icons";
// plane imports
// helpers
import type { TAuthErrorInfo } from "@/helpers/authentication.helper";
import type React from "react";
type TAuthBanner = {
bannerData: TAuthErrorInfo | undefined;
handleBannerData?: (bannerData: TAuthErrorInfo | undefined) => void;
message: React.ReactNode;
handleBannerData?: (bannerData: undefined) => void;
};
export function AuthBanner(props: TAuthBanner) {
const { bannerData, handleBannerData } = props;
const { message, handleBannerData } = props;
// translation
const { t } = useTranslation();
if (!bannerData) return <></>;
if (!message) return <></>;
return (
<div
role="alert"
@ -26,7 +24,7 @@ export function AuthBanner(props: TAuthBanner) {
<div className="size-4 flex-shrink-0 grid place-items-center">
<Info size={16} className="text-accent-primary" />
</div>
<p className="w-full text-13 font-medium text-accent-primary">{bannerData?.message}</p>
<p className="w-full text-13 font-medium text-accent-primary">{message}</p>
<button
type="button"
className="relative ml-auto size-6 rounded-xs grid place-items-center transition-all hover:bg-accent-primary/20 text-accent-primary/80"

View file

@ -1,4 +1,3 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
import { useTranslation } from "@plane/i18n";
@ -8,8 +7,8 @@ import { LogoSpinner } from "@/components/common/logo-spinner";
import { WorkspaceLogo } from "@/components/workspace/logo";
// helpers
import { EAuthModes, EAuthSteps } from "@/helpers/authentication.helper";
import { WorkspaceService } from "@/plane-web/services";
// services
import { WorkspaceService } from "@/plane-web/services";
type TAuthHeader = {
workspaceSlug: string | undefined;
@ -101,10 +100,19 @@ export const AuthHeader = observer(function AuthHeader(props: TAuthHeader) {
</div>
);
return <AuthHeaderBase subHeader={subHeader} header={header} />;
});
type TAuthHeaderBase = {
header: React.ReactNode;
subHeader: string;
};
export function AuthHeaderBase(props: TAuthHeaderBase) {
return (
<div className="flex flex-col gap-1">
<span className="text-h4-semibold text-primary">{typeof header === "string" ? t(header) : header}</span>
<span className="text-h4-semibold text-placeholder">{subHeader}</span>
<span className="text-h4-semibold text-primary">{props.header}</span>
<span className="text-h4-semibold text-placeholder">{props.subHeader}</span>
</div>
);
});
}

View file

@ -1,7 +1,6 @@
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
import { useTheme } from "next-themes";
// plane imports
import { OAuthOptions } from "@plane/ui";
// helpers
@ -14,14 +13,12 @@ import {
authErrorHandler,
} from "@/helpers/authentication.helper";
// hooks
import { useInstance } from "@/hooks/store/use-instance";
import { useOAuthConfig } from "@/hooks/oauth";
// local imports
import { TermsAndConditions } from "../terms-and-conditions";
import { AuthBanner } from "./auth-banner";
import { AuthHeader } from "./auth-header";
import { AuthFormRoot } from "./form-root";
// plane web imports
import { OAUTH_CONFIG, isOAuthEnabled as isOAuthEnabledHelper } from "@/plane-web/helpers/oauth-config";
type TAuthRoot = {
authMode: EAuthModes;
@ -35,8 +32,6 @@ export const AuthRoot = observer(function AuthRoot(props: TAuthRoot) {
const invitation_id = searchParams.get("invitation_id");
const workspaceSlug = searchParams.get("slug");
const error_code = searchParams.get("error_code");
const next_path = searchParams.get("next_path");
const { resolvedTheme } = useTheme();
// props
const { authMode: currentAuthMode } = props;
// states
@ -44,12 +39,9 @@ export const AuthRoot = observer(function AuthRoot(props: TAuthRoot) {
const [authStep, setAuthStep] = useState<EAuthSteps>(EAuthSteps.EMAIL);
const [email, setEmail] = useState(emailParam ? emailParam.toString() : "");
const [errorInfo, setErrorInfo] = useState<TAuthErrorInfo | undefined>(undefined);
// hooks
const { config } = useInstance();
// derived values
const isOAuthEnabled = isOAuthEnabledHelper(config);
const oAuthActionText = authMode === EAuthModes.SIGN_UP ? "Sign up" : "Sign in";
const { isOAuthEnabled, oAuthOptions } = useOAuthConfig(oAuthActionText);
useEffect(() => {
if (!authMode && currentAuthMode) setAuthMode(currentAuthMode);
@ -99,15 +91,11 @@ export const AuthRoot = observer(function AuthRoot(props: TAuthRoot) {
if (!authMode) return <></>;
const OauthButtonContent = authMode === EAuthModes.SIGN_UP ? "Sign up" : "Sign in";
const OAuthConfig = OAUTH_CONFIG({ OauthButtonContent, next_path, config, resolvedTheme });
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">
{errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && (
<AuthBanner bannerData={errorInfo} handleBannerData={(value) => setErrorInfo(value)} />
<AuthBanner message={errorInfo.message} handleBannerData={(value) => setErrorInfo(value)} />
)}
<AuthHeader
workspaceSlug={workspaceSlug?.toString() || undefined}
@ -117,7 +105,7 @@ export const AuthRoot = observer(function AuthRoot(props: TAuthRoot) {
currentAuthStep={authStep}
/>
{isOAuthEnabled && <OAuthOptions options={OAuthConfig} compact={authStep === EAuthSteps.PASSWORD} />}
{isOAuthEnabled && <OAuthOptions options={oAuthOptions} compact={authStep === EAuthSteps.PASSWORD} />}
<AuthFormRoot
authStep={authStep}

View file

@ -96,7 +96,7 @@ export const ResetPasswordForm = observer(function ResetPasswordForm() {
<AuthFormHeader title="Reset password" description="Create a new password." />
{errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && (
<AuthBanner bannerData={errorInfo} handleBannerData={(value) => setErrorInfo(value)} />
<AuthBanner message={errorInfo.message} handleBannerData={(value) => setErrorInfo(value)} />
)}
<form
className="space-y-4"

View file

@ -33,14 +33,12 @@ export const AuthHeader = observer(function AuthHeader({ type }: AuthHeaderProps
const { config } = useInstance();
// derived values
const enableSignUpConfig = config?.enable_signup ?? false;
return (
<>
<PageHead title={t(authContentMap[type].pageTitle) + " - Plane"} />
<div className="flex items-center justify-between gap-6 w-full flex-shrink-0 sticky top-0">
<Link href="/">
<PlaneLockup height={20} width={95} className="text-primary" />
</Link>
{enableSignUpConfig && (
<AuthHeaderBase
pageTitle={t(authContentMap[type].pageTitle)}
additionalAction={
enableSignUpConfig && (
<div className="flex flex-col items-end text-13 font-medium text-center sm:items-center sm:gap-2 sm:flex-row text-tertiary">
<span className="text-body-sm-regular text-tertiary">{t(authContentMap[type].text)}</span>
<Link
@ -51,8 +49,28 @@ export const AuthHeader = observer(function AuthHeader({ type }: AuthHeaderProps
{t(authContentMap[type].linkText)}
</Link>
</div>
)}
)
}
/>
);
});
type TAuthHeaderBase = {
pageTitle: string;
additionalAction?: React.ReactNode;
};
export function AuthHeaderBase(props: TAuthHeaderBase) {
const { pageTitle, additionalAction } = props;
return (
<>
<PageHead title={pageTitle + " - Plane"} />
<div className="flex items-center justify-between gap-6 w-full flex-shrink-0 sticky top-0">
<Link href="/">
<PlaneLockup height={20} width={95} className="text-primary" />
</Link>
{additionalAction}
</div>
</>
);
});
}

View file

@ -0,0 +1,82 @@
// plane imports
import { useSearchParams } from "next/navigation";
import { useTheme } from "next-themes";
import { API_BASE_URL } from "@plane/constants";
import type { TOAuthConfigs, TOAuthOption } from "@plane/types";
// 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";
// hooks
import { useInstance } from "@/hooks/store/use-instance";
export const useCoreOAuthConfig = (oauthActionText: string): TOAuthConfigs => {
//router
const searchParams = useSearchParams();
// query params
const next_path = searchParams.get("next_path");
// theme
const { resolvedTheme } = useTheme();
// store hooks
const { config } = useInstance();
// derived values
const isOAuthEnabled =
(config &&
(config?.is_google_enabled ||
config?.is_github_enabled ||
config?.is_gitlab_enabled ||
config?.is_gitea_enabled)) ||
false;
const oAuthOptions: TOAuthOption[] = [
{
id: "google",
text: `${oauthActionText} 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: `${oauthActionText} with GitHub`,
icon: (
<img
src={resolvedTheme === "dark" ? GithubDarkLogo : GithubLightLogo}
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: `${oauthActionText} 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: `${oauthActionText} 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 {
isOAuthEnabled,
oAuthOptions,
};
};

View file

@ -0,0 +1,9 @@
// plane imports
import type { TOAuthConfigs } from "@plane/types";
export const useExtendedOAuthConfig = (_oauthActionText: string): TOAuthConfigs => {
return {
isOAuthEnabled: false,
oAuthOptions: [],
};
};

View file

@ -0,0 +1,14 @@
// plane imports
import type { TOAuthConfigs } from "@plane/types";
// local imports
import { useCoreOAuthConfig } from "./core";
import { useExtendedOAuthConfig } from "./extended";
export const useOAuthConfig = (oauthActionText: string = "Continue"): TOAuthConfigs => {
const coreOAuthConfig = useCoreOAuthConfig(oauthActionText);
const extendedOAuthConfig = useExtendedOAuthConfig(oauthActionText);
return {
isOAuthEnabled: coreOAuthConfig.isOAuthEnabled || extendedOAuthConfig.isOAuthEnabled,
oAuthOptions: [...coreOAuthConfig.oAuthOptions, ...extendedOAuthConfig.oAuthOptions],
};
};

View file

@ -187,7 +187,7 @@ export const useYjsSetup = ({ docId, serverUrl, authToken, onStateChange }: UseY
provider.on("close", handleClose);
setYjsSession({ provider, ydoc: provider.document as Y.Doc });
setYjsSession({ provider, ydoc: provider.document });
// Handle page visibility changes (sleep/wake, tab switching)
const handleVisibilityChange = (event?: Event) => {

View file

@ -1 +1,3 @@
export type TExtendedLoginMediums = never;
export type TExtendedInstanceAuthenticationModeKeys = never;

View file

@ -1,5 +1,19 @@
import type { TExtendedInstanceAuthenticationModeKeys } from "./auth-ee";
export type TCoreInstanceAuthenticationModeKeys =
| "unique-codes"
| "passwords-login"
| "google"
| "github"
| "gitlab"
| "gitea";
export type TInstanceAuthenticationModeKeys =
| TCoreInstanceAuthenticationModeKeys
| TExtendedInstanceAuthenticationModeKeys;
export type TInstanceAuthenticationModes = {
key: string;
key: TInstanceAuthenticationModeKeys;
name: string;
description: string;
icon: React.ReactNode;
@ -53,4 +67,17 @@ export type TGetBaseAuthenticationModeProps = {
resolvedTheme: string | undefined;
};
export type TOAuthOption = {
id: string;
text: string;
icon: React.ReactNode;
onClick: () => void;
enabled?: boolean;
};
export type TOAuthConfigs = {
isOAuthEnabled: boolean;
oAuthOptions: TOAuthOption[];
};
export type TCoreLoginMediums = "email" | "magic-code" | "github" | "gitlab" | "google" | "gitea";