[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,15 +17,12 @@ 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.
</div>
</div>
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
<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} />
) : (
@ -35,9 +35,7 @@ const InstanceAIPage = observer(function InstanceAIPage(_props: Route.ComponentP
<Loader.Item height="50px" width="20%" />
</Loader>
)}
</div>
</div>
</>
</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,9 +59,8 @@ 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">
<PageWrapper
customHeader={
<AuthenticationMethodCard
name="Gitea"
description="Allow members to login or sign up to plane with their Gitea accounts."
@ -76,8 +78,8 @@ const InstanceGiteaAuthenticationPage = observer(function InstanceGiteaAuthentic
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} />
) : (
@ -89,9 +91,7 @@ const InstanceGiteaAuthenticationPage = observer(function InstanceGiteaAuthentic
<Loader.Item height="50px" width="50%" />
</Loader>
)}
</div>
</div>
</>
</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,9 +67,8 @@ 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">
<PageWrapper
customHeader={
<AuthenticationMethodCard
name="GitHub"
description="Allow members to login or sign up to plane with their GitHub accounts."
@ -92,8 +93,8 @@ const InstanceGithubAuthenticationPage = observer(function InstanceGithubAuthent
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} />
) : (
@ -105,9 +106,7 @@ const InstanceGithubAuthenticationPage = observer(function InstanceGithubAuthent
<Loader.Item height="50px" width="50%" />
</Loader>
)}
</div>
</div>
</>
</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,9 +58,8 @@ 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">
<PageWrapper
customHeader={
<AuthenticationMethodCard
name="GitLab"
description="Allow members to login or sign up to plane with their GitLab accounts."
@ -80,8 +81,8 @@ const InstanceGitlabAuthenticationPage = observer(function InstanceGitlabAuthent
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} />
) : (
@ -93,9 +94,7 @@ const InstanceGitlabAuthenticationPage = observer(function InstanceGitlabAuthent
<Loader.Item height="50px" width="50%" />
</Loader>
)}
</div>
</div>
</>
</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,9 +58,8 @@ 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">
<PageWrapper
customHeader={
<AuthenticationMethodCard
name="Google"
description="Allow members to login or sign up to plane with their Google
@ -81,8 +82,8 @@ const InstanceGoogleAuthenticationPage = observer(function InstanceGoogleAuthent
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} />
) : (
@ -94,9 +95,7 @@ const InstanceGoogleAuthenticationPage = observer(function InstanceGoogleAuthent
<Loader.Item height="50px" width="50%" />
</Loader>
)}
</div>
</div>
</>
</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,16 +60,14 @@ 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">
<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")}>
@ -92,8 +96,18 @@ const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(
</div>
</div>
</div>
<div className="text-16 font-medium pt-6">Available authentication modes</div>
<AuthenticationModes disabled={isSubmitting} updateConfig={updateConfig} />
<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>
) : (
<Loader className="space-y-10">
@ -104,9 +118,7 @@ const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(
<Loader.Item height="50px" width="20%" />
</Loader>
)}
</div>
</div>
</>
</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,29 +52,29 @@ const InstanceEmailPage = observer(function InstanceEmailPage(_props: Route.Comp
}, [formattedConfig]);
return (
<PageWrapper
header={{
title: "Secure emails from your own instance",
description: (
<>
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="flex items-center justify-between gap-4 border-b border-subtle mx-4 py-4 space-y-1 flex-shrink-0">
<div className="py-4 space-y-1 flex-shrink-0">
<div className="text-18 font-medium text-primary">Secure emails from your own instance</div>
<div className="text-13 font-regular text-tertiary">
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>
</div>
</div>
{isLoading ? (
</>
),
actions: isLoading ? (
<Loader>
<Loader.Item width="24px" height="16px" className="rounded-full" />
</Loader>
) : (
<ToggleSwitch value={isSMTPEnabled} onChange={handleToggle} size="sm" disabled={isSubmitting} />
)}
</div>
),
}}
>
{isSMTPEnabled && !isLoading && (
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
<>
{formattedConfig ? (
<InstanceEmailForm config={formattedConfig} />
) : (
@ -83,10 +86,9 @@ const InstanceEmailPage = observer(function InstanceEmailPage(_props: Route.Comp
<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,15 +17,12 @@ 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">
<PageWrapper
header={{
title: "Third-party image libraries",
description: "Let your users search and choose images from third-party libraries",
}}
>
{formattedConfig ? (
<InstanceImageConfigForm config={formattedConfig} />
) : (
@ -31,9 +31,7 @@ const InstanceImagePage = observer(function InstanceImagePage(_props: Route.Comp
<Loader.Item height="50px" width="20%" />
</Loader>
)}
</div>
</div>
</>
</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">
<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 />
</div>
</div>
</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,14 +69,12 @@ 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">
<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")}>
@ -83,8 +82,7 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
<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.
Toggling this on will let only you create workspaces. You will have to invite users to new workspaces.
</div>
</div>
</div>
@ -126,7 +124,7 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
</div>
</div>
<div className="flex items-center gap-2">
<Link href="/workspace/create" className={getButtonStyling("primary", "lg")}>
<Link href="/workspace/create" className={getButtonStyling("primary", "base")}>
Create workspace
</Link>
</div>
@ -159,8 +157,7 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
</Loader>
)}
</div>
</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";