fix: merge conflicts

This commit is contained in:
sriramveeraghanta
2026-01-23 13:41:51 +05:30
256 changed files with 4922 additions and 5563 deletions
+4 -1
View File
@@ -13,11 +13,14 @@ class IssueForIntakeSerializer(BaseSerializer):
content validation and priority assignment for triage workflows.
"""
description = serializers.JSONField(source="description_json", required=False, allow_null=True)
class Meta:
model = Issue
fields = [
"name",
"description",
"description", # Deprecated
"description_json",
"description_html",
"priority",
]
+2 -1
View File
@@ -65,7 +65,7 @@ class IssueSerializer(BaseSerializer):
class Meta:
model = Issue
read_only_fields = ["id", "workspace", "project", "updated_by", "updated_at"]
exclude = ["description", "description_stripped"]
exclude = ["description_json", "description_stripped"]
def validate(self, data):
if (
@@ -633,6 +633,7 @@ class IssueExpandSerializer(BaseSerializer):
labels = serializers.SerializerMethodField()
assignees = serializers.SerializerMethodField()
state = StateLiteSerializer(read_only=True)
description = serializers.JSONField(source="description_json", read_only=True)
def get_labels(self, obj):
expand = self.context.get("expand", [])
+9 -5
View File
@@ -180,11 +180,14 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
)
# create an issue
issue_data = request.data.get("issue", {})
# Accept both "description" and "description_json" keys for the description_json field
description_json = issue_data.get("description") or issue_data.get("description_json") or {}
issue = Issue.objects.create(
name=request.data.get("issue", {}).get("name"),
description=request.data.get("issue", {}).get("description", {}),
description_html=request.data.get("issue", {}).get("description_html", "<p></p>"),
priority=request.data.get("issue", {}).get("priority", "none"),
name=issue_data.get("name"),
description_json=description_json,
description_html=issue_data.get("description_html", "<p></p>"),
priority=issue_data.get("priority", "none"),
project_id=project_id,
state_id=triage_state.id,
)
@@ -365,10 +368,11 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
# Only allow guests to edit name and description
if project_member.role <= 5:
description_json = issue_data.get("description") or issue_data.get("description_json") or {}
issue_data = {
"name": issue_data.get("name", issue.name),
"description_html": issue_data.get("description_html", issue.description_html),
"description": issue_data.get("description", issue.description),
"description_json": description_json,
}
issue_serializer = IssueSerializer(issue, data=issue_data, partial=True)
+1 -1
View File
@@ -53,7 +53,7 @@ class IssueFlatSerializer(BaseSerializer):
fields = [
"id",
"name",
"description",
"description_json",
"description_html",
"priority",
"start_date",
+5 -5
View File
@@ -58,7 +58,7 @@ class PageSerializer(BaseSerializer):
labels = validated_data.pop("labels", None)
project_id = self.context["project_id"]
owned_by_id = self.context["owned_by_id"]
description = self.context["description"]
description_json = self.context["description_json"]
description_binary = self.context["description_binary"]
description_html = self.context["description_html"]
@@ -68,7 +68,7 @@ class PageSerializer(BaseSerializer):
# Create the page
page = Page.objects.create(
**validated_data,
description=description,
description_json=description_json,
description_binary=description_binary,
description_html=description_html,
owned_by_id=owned_by_id,
@@ -171,7 +171,7 @@ class PageBinaryUpdateSerializer(serializers.Serializer):
description_binary = serializers.CharField(required=False, allow_blank=True)
description_html = serializers.CharField(required=False, allow_blank=True)
description = serializers.JSONField(required=False, allow_null=True)
description_json = serializers.JSONField(required=False, allow_null=True)
def validate_description_binary(self, value):
"""Validate the base64-encoded binary data"""
@@ -214,8 +214,8 @@ class PageBinaryUpdateSerializer(serializers.Serializer):
if "description_html" in validated_data:
instance.description_html = validated_data.get("description_html")
if "description" in validated_data:
instance.description = validated_data.get("description")
if "description_json" in validated_data:
instance.description_json = validated_data.get("description_json")
instance.save()
return instance
+1 -1
View File
@@ -394,7 +394,7 @@ class IntakeIssueViewSet(BaseViewSet):
issue_data = {
"name": issue_data.get("name", issue.name),
"description_html": issue_data.get("description_html", issue.description_html),
"description": issue_data.get("description", issue.description),
"description_json": issue_data.get("description_json", issue.description_json),
}
issue_current_instance = json.dumps(IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder)
+1 -1
View File
@@ -128,7 +128,7 @@ class PageViewSet(BaseViewSet):
context={
"project_id": project_id,
"owned_by_id": request.user.id,
"description": request.data.get("description", {}),
"description_json": request.data.get("description_json", {}),
"description_binary": request.data.get("description_binary", None),
"description_html": request.data.get("description_html", "<p></p>"),
},
+1 -1
View File
@@ -141,7 +141,7 @@ def copy_s3_objects_of_description_and_assets(entity_name, entity_identifier, pr
external_data = sync_with_external_service(entity_name, updated_html)
if external_data:
entity.description = external_data.get("description")
entity.description_json = external_data.get("description_json")
entity.description_binary = base64.b64decode(external_data.get("description_binary"))
entity.save()
@@ -59,7 +59,7 @@ def sync_issue_description_version(batch_size=5000, offset=0, countdown=300):
"description_binary",
"description_html",
"description_stripped",
"description",
"description_json",
)[offset:end_offset]
)
@@ -92,7 +92,7 @@ def sync_issue_description_version(batch_size=5000, offset=0, countdown=300):
description_binary=issue.description_binary,
description_html=issue.description_html,
description_stripped=issue.description_stripped,
description_json=issue.description,
description_json=issue.description_json,
)
)
@@ -19,7 +19,7 @@ def should_update_existing_version(
def update_existing_version(version: IssueDescriptionVersion, issue) -> None:
version.description_json = issue.description
version.description_json = issue.description_json
version.description_html = issue.description_html
version.description_binary = issue.description_binary
version.description_stripped = issue.description_stripped
+1 -1
View File
@@ -28,7 +28,7 @@ def page_version(page_id, existing_instance, user_id):
description_binary=page.description_binary,
owned_by_id=user_id,
last_saved_at=page.updated_at,
description_json=page.description,
description_json=page.description_json,
description_stripped=page.description_stripped,
)
@@ -359,7 +359,7 @@ def create_pages(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_us
is_global=False,
access=page_seed.get("access", Page.PUBLIC_ACCESS),
name=page_seed.get("name"),
description=page_seed.get("description", {}),
description_json=page_seed.get("description_json", {}),
description_html=page_seed.get("description_html", "<p></p>"),
description_binary=page_seed.get("description_binary", None),
description_stripped=page_seed.get("description_stripped", None),
@@ -0,0 +1,38 @@
# Generated by Django 4.2.27 on 2026-01-13 10:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('db', '0115_auto_20260105_1406'),
]
operations = [
migrations.AddField(
model_name='profile',
name='notification_view_mode',
field=models.CharField(choices=[('full', 'Full'), ('compact', 'Compact')], default='full', max_length=255),
),
migrations.AddField(
model_name='user',
name='is_password_reset_required',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='workspacemember',
name='explored_features',
field=models.JSONField(default=dict),
),
migrations.AddField(
model_name='workspacemember',
name='getting_started_checklist',
field=models.JSONField(default=dict),
),
migrations.AddField(
model_name='workspacemember',
name='tips',
field=models.JSONField(default=dict),
),
]
@@ -0,0 +1,28 @@
# Generated by Django 4.2.22 on 2026-01-15 09:02
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('db', '0116_workspacemember_explored_features_and_more'),
]
operations = [
migrations.RenameField(
model_name='draftissue',
old_name='description',
new_name='description_json',
),
migrations.RenameField(
model_name='issue',
old_name='description',
new_name='description_json',
),
migrations.RenameField(
model_name='page',
old_name='description',
new_name='description_json',
),
]
+1 -1
View File
@@ -39,7 +39,7 @@ class DraftIssue(WorkspaceBaseModel):
blank=True,
)
name = models.CharField(max_length=255, verbose_name="Issue Name", blank=True, null=True)
description = models.JSONField(blank=True, default=dict)
description_json = models.JSONField(blank=True, default=dict)
description_html = models.TextField(blank=True, default="<p></p>")
description_stripped = models.TextField(blank=True, null=True)
description_binary = models.BinaryField(null=True)
+2 -2
View File
@@ -128,7 +128,7 @@ class Issue(ProjectBaseModel):
blank=True,
)
name = models.CharField(max_length=255, verbose_name="Issue Name")
description = models.JSONField(blank=True, default=dict)
description_json = models.JSONField(blank=True, default=dict)
description_html = models.TextField(blank=True, default="<p></p>")
description_stripped = models.TextField(blank=True, null=True)
description_binary = models.BinaryField(null=True)
@@ -800,7 +800,7 @@ class IssueDescriptionVersion(ProjectBaseModel):
description_binary=issue.description_binary,
description_html=issue.description_html,
description_stripped=issue.description_stripped,
description_json=issue.description,
description_json=issue.description_json,
)
return True
except Exception as e:
+1 -1
View File
@@ -25,7 +25,7 @@ class Page(BaseModel):
workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="pages")
name = models.TextField(blank=True)
description = models.JSONField(default=dict, blank=True)
description_json = models.JSONField(default=dict, blank=True)
description_binary = models.BinaryField(null=True)
description_html = models.TextField(blank=True, default="<p></p>")
description_stripped = models.TextField(blank=True, null=True)
+8 -2
View File
@@ -84,7 +84,7 @@ class User(AbstractBaseUser, PermissionsMixin):
is_staff = models.BooleanField(default=False)
is_email_verified = models.BooleanField(default=False)
is_password_autoset = models.BooleanField(default=False)
is_password_reset_required = models.BooleanField(default=False)
# random token generated
token = models.CharField(max_length=64, blank=True)
@@ -192,6 +192,10 @@ class Profile(TimeAuditModel):
FRIDAY = 5
SATURDAY = 6
class NotificationViewMode(models.TextChoices):
FULL = "full", "Full"
COMPACT = "compact", "Compact"
START_OF_THE_WEEK_CHOICES = (
(SUNDAY, "Sunday"),
(MONDAY, "Monday"),
@@ -221,7 +225,9 @@ class Profile(TimeAuditModel):
billing_address = models.JSONField(null=True)
has_billing_address = models.BooleanField(default=False)
company_name = models.CharField(max_length=255, blank=True)
notification_view_mode = models.CharField(
max_length=255, choices=NotificationViewMode.choices, default=NotificationViewMode.FULL
)
is_smooth_cursor_enabled = models.BooleanField(default=False)
# mobile
is_mobile_onboarded = models.BooleanField(default=False)
+3
View File
@@ -214,6 +214,9 @@ class WorkspaceMember(BaseModel):
default_props = models.JSONField(default=get_default_props)
issue_props = models.JSONField(default=get_issue_props)
is_active = models.BooleanField(default=True)
getting_started_checklist = models.JSONField(default=dict)
tips = models.JSONField(default=dict)
explored_features = models.JSONField(default=dict)
class Meta:
unique_together = ["workspace", "member", "deleted_at"]
+3
View File
@@ -380,6 +380,7 @@ ATTACHMENT_MIME_TYPES = [
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"text/plain",
"text/markdown",
"application/rtf",
"application/vnd.oasis.opendocument.spreadsheet",
"application/vnd.oasis.opendocument.text",
@@ -447,6 +448,8 @@ ATTACHMENT_MIME_TYPES = [
"application/x-sql",
# Gzip
"application/x-gzip",
# Markdown
"text/markdown",
]
# Seed directory path
+1 -1
View File
@@ -193,7 +193,7 @@ class IssueFlatSerializer(BaseSerializer):
fields = [
"id",
"name",
"description",
"description_json",
"description_html",
"priority",
"start_date",
+2 -2
View File
@@ -140,7 +140,7 @@ class IntakeIssuePublicViewSet(BaseViewSet):
# create an issue
issue = Issue.objects.create(
name=request.data.get("issue", {}).get("name"),
description=request.data.get("issue", {}).get("description", {}),
description_json=request.data.get("issue", {}).get("description_json", {}),
description_html=request.data.get("issue", {}).get("description_html", "<p></p>"),
priority=request.data.get("issue", {}).get("priority", "low"),
project_id=project_deploy_board.project_id,
@@ -201,7 +201,7 @@ class IntakeIssuePublicViewSet(BaseViewSet):
issue_data = {
"name": issue_data.get("name", issue.name),
"description_html": issue_data.get("description_html", issue.description_html),
"description": issue_data.get("description", issue.description),
"description_json": issue_data.get("description_json", issue.description_json),
}
issue_serializer = IssueCreateSerializer(
+1 -1
View File
@@ -744,7 +744,7 @@ class IssueRetrievePublicEndpoint(BaseAPIView):
"name",
"state_id",
"sort_order",
"description",
"description_json",
"description_html",
"description_stripped",
"description_binary",
@@ -27,14 +27,14 @@ export class DocumentController {
const { description_html, variant } = validatedData;
// Process document conversion
const { description, description_binary } = convertHTMLDocumentToAllFormats({
const { description_json, description_binary } = convertHTMLDocumentToAllFormats({
document_html: description_html,
variant,
});
// Return successful response
res.status(200).json({
description,
description_json,
description_binary,
});
} catch (error) {
+7 -6
View File
@@ -1,11 +1,12 @@
import { Database as HocuspocusDatabase } from "@hocuspocus/extension-database";
// utils
// plane imports
import {
getAllDocumentFormatsFromDocumentEditorBinaryData,
getBinaryDataFromDocumentEditorHTMLString,
} from "@plane/editor";
// logger
import type { TDocumentPayload } from "@plane/types";
import { logger } from "@plane/logger";
// lib
import { AppError } from "@/lib/errors";
// services
import { getPageService } from "@/services/page/handler";
@@ -36,10 +37,10 @@ const fetchDocument = async ({ context, documentName: pageId, instance }: FetchP
convertedBinaryData,
true
);
const payload = {
const payload: TDocumentPayload = {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
description_json: contentJSON,
};
await service.updateDescriptionBinary(pageId, payload);
} catch (e) {
@@ -76,10 +77,10 @@ const storeDocument = async ({
true
);
// create payload
const payload = {
const payload: TDocumentPayload = {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
description_json: contentJSON,
};
await service.updateDescriptionBinary(pageId, payload);
} catch (error) {
+2 -8
View File
@@ -1,15 +1,9 @@
import { logger } from "@plane/logger";
import type { TPage } from "@plane/types";
import type { TDocumentPayload, TPage } from "@plane/types";
// services
import { AppError } from "@/lib/errors";
import { APIService } from "../api.service";
export type TPageDescriptionPayload = {
description_binary: string;
description_html: string;
description: object;
};
export abstract class PageCoreService extends APIService {
protected abstract basePath: string;
@@ -103,7 +97,7 @@ export abstract class PageCoreService extends APIService {
}
}
async updateDescriptionBinary(pageId: string, data: TPageDescriptionPayload): Promise<any> {
async updateDescriptionBinary(pageId: string, data: TDocumentPayload): Promise<any> {
return this.patch(`${this.basePath}/pages/${pageId}/description/`, data, {
headers: this.getHeader(),
})
@@ -98,7 +98,7 @@ export const AuthRoot = observer(function AuthRoot() {
}
if (currentAuthMode === EAuthModes.SIGN_IN) {
if (response.is_password_autoset && isSMTPConfigured && isMagicLoginEnabled) {
if (isSMTPConfigured && isMagicLoginEnabled && response.status === "MAGIC_CODE") {
setAuthStep(EAuthSteps.UNIQUE_CODE);
generateEmailUniqueCode(data.email);
} else if (isEmailPasswordEnabled) {
@@ -109,7 +109,7 @@ export const AuthRoot = observer(function AuthRoot() {
setErrorInfo(errorhandler);
}
} else {
if (isSMTPConfigured && isMagicLoginEnabled) {
if (isSMTPConfigured && isMagicLoginEnabled && response.status === "MAGIC_CODE") {
setAuthStep(EAuthSteps.UNIQUE_CODE);
generateEmailUniqueCode(data.email);
} else if (isEmailPasswordEnabled) {
@@ -119,6 +119,7 @@ export const AuthRoot = observer(function AuthRoot() {
setErrorInfo(errorhandler);
}
}
return;
})
.catch((error) => {
const errorhandler = authErrorHandler(error?.error_code?.toString(), data?.email || undefined);
@@ -218,7 +218,7 @@ export const removeNillKeys = <T,>(obj: T) =>
Object.fromEntries(Object.entries(obj ?? {}).filter(([key, value]) => key && !isNil(value)));
/**
* This Method returns if the the grouped values are subGrouped
* This Method returns if the grouped values are subGrouped
* @param groupedIssueIds
* @returns
*/
+1
View File
@@ -19,6 +19,7 @@ export interface IEmailCheckData {
}
export interface IEmailCheckResponse {
status: "MAGIC_CODE" | "CREDENTIAL";
is_password_autoset: boolean;
existing: boolean;
}
@@ -2,7 +2,6 @@ import { Outlet } from "react-router";
// components
import { ContentWrapper } from "@/components/core/content-wrapper";
import { ProjectsAppPowerKProvider } from "@/components/power-k/projects-app-provider";
import { SettingsHeader } from "@/components/settings/header";
export default function SettingsLayout() {
return (
@@ -10,10 +9,8 @@ export default function SettingsLayout() {
<ProjectsAppPowerKProvider />
<div className="relative flex size-full overflow-hidden rounded-lg border border-subtle">
<main className="relative flex size-full flex-col overflow-hidden">
{/* Header */}
<SettingsHeader />
{/* Content */}
<ContentWrapper className="p-page-x md:flex w-full bg-surface-1">
<ContentWrapper className="md:flex w-full bg-surface-1">
<div className="size-full overflow-hidden">
<Outlet />
</div>
@@ -0,0 +1,36 @@
import { observer } from "mobx-react";
// plane imports
import { WORKSPACE_SETTINGS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Breadcrumbs } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { SettingsPageHeader } from "@/components/settings/page-header";
import { WORKSPACE_SETTINGS_ICONS } from "@/components/settings/workspace/sidebar/item-icon";
export const BillingWorkspaceSettingsHeader = observer(function BillingWorkspaceSettingsHeader() {
// translation
const { t } = useTranslation();
// derived values
const settingsDetails = WORKSPACE_SETTINGS["billing-and-plans"];
const Icon = WORKSPACE_SETTINGS_ICONS["billing-and-plans"];
return (
<SettingsPageHeader
leftItem={
<div className="flex items-center gap-2">
<Breadcrumbs>
<Breadcrumbs.Item
component={
<BreadcrumbLink
label={t(settingsDetails.i18n_label)}
icon={<Icon className="size-4 text-tertiary" />}
/>
}
/>
</Breadcrumbs>
</div>
}
/>
);
});
@@ -3,12 +3,14 @@ import { observer } from "mobx-react";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
import { PageHead } from "@/components/core/page-title";
// hooks
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
// hooks
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUserPermissions } from "@/hooks/store/user";
// plane web components
import { BillingRoot } from "@/plane-web/components/workspace/billing";
// local imports
import { BillingWorkspaceSettingsHeader } from "./header";
function BillingSettingsPage() {
// store hooks
@@ -23,7 +25,7 @@ function BillingSettingsPage() {
}
return (
<SettingsContentWrapper size="lg">
<SettingsContentWrapper header={<BillingWorkspaceSettingsHeader />} hugging>
<PageHead title={pageTitle} />
<BillingRoot />
</SettingsContentWrapper>
@@ -0,0 +1,36 @@
import { observer } from "mobx-react";
// plane imports
import { WORKSPACE_SETTINGS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Breadcrumbs } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { SettingsPageHeader } from "@/components/settings/page-header";
import { WORKSPACE_SETTINGS_ICONS } from "@/components/settings/workspace/sidebar/item-icon";
export const ExportsWorkspaceSettingsHeader = observer(function ExportsWorkspaceSettingsHeader() {
// translation
const { t } = useTranslation();
// derived values
const settingsDetails = WORKSPACE_SETTINGS.export;
const Icon = WORKSPACE_SETTINGS_ICONS.export;
return (
<SettingsPageHeader
leftItem={
<div className="flex items-center gap-2">
<Breadcrumbs>
<Breadcrumbs.Item
component={
<BreadcrumbLink
label={t(settingsDetails.i18n_label)}
icon={<Icon className="size-4 text-tertiary" />}
/>
}
/>
</Breadcrumbs>
</div>
}
/>
);
});
@@ -1,17 +1,18 @@
import { observer } from "mobx-react";
// components
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { cn } from "@plane/utils";
// components
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
import { PageHead } from "@/components/core/page-title";
import ExportGuide from "@/components/exporter/guide";
// helpers
// hooks
import { ExportGuide } from "@/components/exporter/guide";
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
import SettingsHeading from "@/components/settings/heading";
import { SettingsHeading } from "@/components/settings/heading";
// hooks
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUserPermissions } from "@/hooks/store/user";
// local imports
import { ExportsWorkspaceSettingsHeader } from "./header";
function ExportsPage() {
// store hooks
@@ -34,10 +35,10 @@ function ExportsPage() {
}
return (
<SettingsContentWrapper size="lg">
<SettingsContentWrapper header={<ExportsWorkspaceSettingsHeader />} hugging>
<PageHead title={pageTitle} />
<div
className={cn("w-full", {
className={cn("w-full flex flex-col gap-y-6", {
"opacity-60": !canPerformWorkspaceMemberActions,
})}
>
@@ -0,0 +1,36 @@
import { observer } from "mobx-react";
// plane imports
import { WORKSPACE_SETTINGS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Breadcrumbs } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { SettingsPageHeader } from "@/components/settings/page-header";
import { WORKSPACE_SETTINGS_ICONS } from "@/components/settings/workspace/sidebar/item-icon";
export const GeneralWorkspaceSettingsHeader = observer(function GeneralWorkspaceSettingsHeader() {
// translation
const { t } = useTranslation();
// derived values
const settingsDetails = WORKSPACE_SETTINGS.general;
const Icon = WORKSPACE_SETTINGS_ICONS.general;
return (
<SettingsPageHeader
leftItem={
<div className="flex items-center gap-2">
<Breadcrumbs>
<Breadcrumbs.Item
component={
<BreadcrumbLink
label={t(settingsDetails.i18n_label)}
icon={<Icon className="size-4 text-tertiary" />}
/>
}
/>
</Breadcrumbs>
</div>
}
/>
);
});
@@ -1,35 +0,0 @@
import { observer } from "mobx-react";
// components
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
import { PageHead } from "@/components/core/page-title";
import IntegrationGuide from "@/components/integration/guide";
// hooks
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
import { SettingsHeading } from "@/components/settings/heading";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUserPermissions } from "@/hooks/store/user";
function ImportsPage() {
// router
// store hooks
const { currentWorkspace } = useWorkspace();
const { allowPermissions } = useUserPermissions();
// derived values
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Imports` : undefined;
if (!isAdmin) return <NotAuthorizedView section="settings" className="h-auto" />;
return (
<SettingsContentWrapper size="lg">
<PageHead title={pageTitle} />
<section className="w-full">
<SettingsHeading title="Imports" />
<IntegrationGuide />
</section>
</SettingsContentWrapper>
);
}
export default observer(ImportsPage);
@@ -4,8 +4,7 @@ import useSWR from "swr";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
import { PageHead } from "@/components/core/page-title";
import { SingleIntegrationCard } from "@/components/integration";
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
import { SingleIntegrationCard } from "@/components/integration/single-integration-card";
import { IntegrationAndImportExportBanner } from "@/components/ui/integration-and-import-export-banner";
import { IntegrationsSettingsLoader } from "@/components/ui/loader/settings/integration";
// constants
@@ -33,7 +32,7 @@ function WorkspaceIntegrationsPage() {
if (!isAdmin) return <NotAuthorizedView section="settings" className="h-auto" />;
return (
<SettingsContentWrapper size="lg">
<>
<PageHead title={pageTitle} />
<section className="w-full overflow-y-auto">
<IntegrationAndImportExportBanner bannerName="Integrations" />
@@ -47,7 +46,7 @@ function WorkspaceIntegrationsPage() {
)}
</div>
</section>
</SettingsContentWrapper>
</>
);
}
@@ -4,14 +4,14 @@ import { Outlet } from "react-router";
// components
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
import { getWorkspaceActivePath, pathnameToAccessKey } from "@/components/settings/helper";
import { SettingsMobileNav } from "@/components/settings/mobile";
import { SettingsMobileNav } from "@/components/settings/mobile/nav";
// plane imports
import { WORKSPACE_SETTINGS_ACCESS } from "@plane/constants";
import type { EUserWorkspaceRoles } from "@plane/types";
// components
import { WorkspaceSettingsSidebarRoot } from "@/components/settings/workspace/sidebar";
// hooks
import { useUserPermissions } from "@/hooks/store/user";
// local components
import { WorkspaceSettingsSidebar } from "./sidebar";
import type { Route } from "./+types/layout";
@@ -34,18 +34,18 @@ const WorkspaceSettingLayout = observer(function WorkspaceSettingLayout({ params
return (
<>
<SettingsMobileNav
hamburgerContent={WorkspaceSettingsSidebar}
hamburgerContent={WorkspaceSettingsSidebarRoot}
activePath={getWorkspaceActivePath(pathname) || ""}
/>
<div className="inset-y-0 flex flex-row w-full h-full">
{workspaceUserInfo && !isAuthorized ? (
<NotAuthorizedView section="settings" className="h-auto" />
) : (
<div className="relative flex h-full w-full">
<div className="hidden md:block">{<WorkspaceSettingsSidebar />}</div>
<div className="w-full h-full overflow-y-scroll md:pt-page-y">
<Outlet />
<div className="relative flex size-full">
<div className="h-full hidden md:block">
<WorkspaceSettingsSidebarRoot />
</div>
<Outlet />
</div>
)}
</div>
@@ -0,0 +1,36 @@
import { observer } from "mobx-react";
// plane imports
import { WORKSPACE_SETTINGS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Breadcrumbs } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { SettingsPageHeader } from "@/components/settings/page-header";
import { WORKSPACE_SETTINGS_ICONS } from "@/components/settings/workspace/sidebar/item-icon";
export const MembersWorkspaceSettingsHeader = observer(function MembersWorkspaceSettingsHeader() {
// plane hooks
const { t } = useTranslation();
// derived values
const settingsDetails = WORKSPACE_SETTINGS.members;
const Icon = WORKSPACE_SETTINGS_ICONS.members;
return (
<SettingsPageHeader
leftItem={
<div className="flex items-center gap-2">
<Breadcrumbs>
<Breadcrumbs.Item
component={
<BreadcrumbLink
label={t(settingsDetails.i18n_label)}
icon={<Icon className="size-4 text-tertiary" />}
/>
}
/>
</Breadcrumbs>
</div>
}
/>
);
});
@@ -13,7 +13,6 @@ import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view
import { CountChip } from "@/components/common/count-chip";
import { PageHead } from "@/components/core/page-title";
import { MemberListFiltersDropdown } from "@/components/project/dropdowns/filters/member-list";
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
import { WorkspaceMembersList } from "@/components/workspace/settings/members-list";
// hooks
import { useMember } from "@/hooks/store/use-member";
@@ -22,7 +21,10 @@ import { useUserPermissions } from "@/hooks/store/user";
// plane web components
import { BillingActionsButton } from "@/plane-web/components/workspace/billing/billing-actions-button";
import { SendWorkspaceInvitationModal, MembersActivityButton } from "@/plane-web/components/workspace/members";
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
// local imports
import type { Route } from "./+types/page";
import { MembersWorkspaceSettingsHeader } from "./header";
const WorkspaceMembersSettingsPage = observer(function WorkspaceMembersSettingsPage({ params }: Route.ComponentProps) {
// states
@@ -93,7 +95,7 @@ const WorkspaceMembersSettingsPage = observer(function WorkspaceMembersSettingsP
}
return (
<SettingsContentWrapper size="lg">
<SettingsContentWrapper header={<MembersWorkspaceSettingsHeader />} hugging>
<PageHead title={pageTitle} />
<SendWorkspaceInvitationModal
isOpen={inviteModal}
@@ -101,12 +103,12 @@ const WorkspaceMembersSettingsPage = observer(function WorkspaceMembersSettingsP
onSubmit={handleWorkspaceInvite}
/>
<section
className={cn("w-full h-full", {
className={cn("size-full", {
"opacity-60": !canPerformWorkspaceMemberActions,
})}
>
<div className="flex justify-between gap-4 pb-3.5 items-center">
<h4 className="flex items-center gap-2.5 text-h5-medium">
<h4 className="flex items-center gap-2.5 text-h3-medium">
{t("workspace_settings.settings.members.title")}
{workspaceMemberIds && workspaceMemberIds.length > 0 && (
<CountChip count={workspaceMemberIds.length} className="h-5 m-auto" />
@@ -1,40 +0,0 @@
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import { WORKSPACE_SETTINGS_LINKS, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// hooks
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web helpers
import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper";
export const MobileWorkspaceSettingsTabs = observer(function MobileWorkspaceSettingsTabs() {
const router = useAppRouter();
const { workspaceSlug } = useParams();
const pathname = usePathname();
const { t } = useTranslation();
// mobx store
const { allowPermissions } = useUserPermissions();
return (
<div className="flex-shrink-0 md:hidden sticky inset-0 flex overflow-x-auto bg-surface-1 z-10">
{WORKSPACE_SETTINGS_LINKS.map(
(item, index) =>
shouldRenderSettingLink(workspaceSlug.toString(), item.key) &&
allowPermissions(item.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString()) && (
<div
className={`${
item.highlight(pathname, `/${workspaceSlug}`)
? "text-accent-primary text-13 py-2 px-3 whitespace-nowrap flex flex-grow cursor-pointer justify-around border-b border-accent-strong-200"
: "text-secondary flex flex-grow cursor-pointer justify-around border-b border-subtle text-13 py-2 px-3 whitespace-nowrap"
}`}
key={index}
onClick={() => router.push(`/${workspaceSlug}${item.href}`)}
>
{t(item.i18n_label)}
</div>
)
)}
</div>
);
});
@@ -7,8 +7,10 @@ import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
import { WorkspaceDetails } from "@/components/workspace/settings/workspace-details";
// hooks
import { useWorkspace } from "@/hooks/store/use-workspace";
// local imports
import { GeneralWorkspaceSettingsHeader } from "./header";
function WorkspaceSettingsPage() {
function GeneralWorkspaceSettingsPage() {
// store hooks
const { currentWorkspace } = useWorkspace();
const { t } = useTranslation();
@@ -18,11 +20,11 @@ function WorkspaceSettingsPage() {
: undefined;
return (
<SettingsContentWrapper>
<SettingsContentWrapper header={<GeneralWorkspaceSettingsHeader />}>
<PageHead title={pageTitle} />
<WorkspaceDetails />
</SettingsContentWrapper>
);
}
export default observer(WorkspaceSettingsPage);
export default observer(GeneralWorkspaceSettingsPage);
@@ -1,72 +0,0 @@
import { useParams, usePathname } from "next/navigation";
import { ArrowUpToLine, Building, CreditCard, Users, Webhook } from "lucide-react";
import type { LucideIcon } from "lucide-react";
// plane imports
import {
EUserPermissionsLevel,
EUserPermissions,
GROUPED_WORKSPACE_SETTINGS,
WORKSPACE_SETTINGS_CATEGORIES,
WORKSPACE_SETTINGS_CATEGORY,
} from "@plane/constants";
import type { WORKSPACE_SETTINGS } from "@plane/constants";
import type { ISvgIcons } from "@plane/propel/icons";
import type { EUserWorkspaceRoles } from "@plane/types";
// components
import { SettingsSidebar } from "@/components/settings/sidebar";
// hooks
import { useUserPermissions } from "@/hooks/store/user";
// plane web imports
import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper";
export const WORKSPACE_SETTINGS_ICONS: Record<keyof typeof WORKSPACE_SETTINGS, LucideIcon | React.FC<ISvgIcons>> = {
general: Building,
members: Users,
export: ArrowUpToLine,
"billing-and-plans": CreditCard,
webhooks: Webhook,
};
export function WorkspaceActionIcons({ type, size, className }: { type: string; size?: number; className?: string }) {
if (type === undefined) return null;
const Icon = WORKSPACE_SETTINGS_ICONS[type as keyof typeof WORKSPACE_SETTINGS_ICONS];
if (!Icon) return null;
return <Icon size={size} className={className} strokeWidth={2} />;
}
type TWorkspaceSettingsSidebarProps = {
isMobile?: boolean;
};
export function WorkspaceSettingsSidebar(props: TWorkspaceSettingsSidebarProps) {
const { isMobile = false } = props;
// router
const pathname = usePathname();
const { workspaceSlug } = useParams(); // store hooks
const { allowPermissions } = useUserPermissions();
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
return (
<SettingsSidebar
isMobile={isMobile}
categories={WORKSPACE_SETTINGS_CATEGORIES.filter(
(category) =>
isAdmin || ![WORKSPACE_SETTINGS_CATEGORY.FEATURES, WORKSPACE_SETTINGS_CATEGORY.DEVELOPER].includes(category)
)}
groupedSettings={GROUPED_WORKSPACE_SETTINGS}
workspaceSlug={workspaceSlug.toString()}
isActive={(data: { href: string }) =>
data.href === "/settings"
? pathname === `/${workspaceSlug}${data.href}/`
: new RegExp(`^/${workspaceSlug}${data.href}/`).test(pathname)
}
shouldRender={(data: { key: string; access?: EUserWorkspaceRoles[] | undefined }) =>
data.access
? shouldRenderSettingLink(workspaceSlug.toString(), data.key) &&
allowPermissions(data.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString())
: false
}
actionIcons={WorkspaceActionIcons}
/>
);
}
@@ -0,0 +1,36 @@
import { observer } from "mobx-react";
// plane imports
import { WORKSPACE_SETTINGS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Breadcrumbs } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { SettingsPageHeader } from "@/components/settings/page-header";
import { WORKSPACE_SETTINGS_ICONS } from "@/components/settings/workspace/sidebar/item-icon";
export const WebhookDetailsWorkspaceSettingsHeader = observer(function WebhookDetailsWorkspaceSettingsHeader() {
// translation
const { t } = useTranslation();
// derived values
const settingsDetails = WORKSPACE_SETTINGS.webhooks;
const Icon = WORKSPACE_SETTINGS_ICONS.webhooks;
return (
<SettingsPageHeader
leftItem={
<div className="flex items-center gap-2">
<Breadcrumbs>
<Breadcrumbs.Item
component={
<BreadcrumbLink
label={t(settingsDetails.i18n_label)}
icon={<Icon className="size-4 text-tertiary" />}
/>
}
/>
</Breadcrumbs>
</div>
}
/>
);
});
@@ -14,7 +14,9 @@ import { DeleteWebhookModal, WebhookDeleteSection, WebhookForm } from "@/compone
import { useWebhook } from "@/hooks/store/use-webhook";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUserPermissions } from "@/hooks/store/user";
// local imports
import type { Route } from "./+types/page";
import { WebhookDetailsWorkspaceSettingsHeader } from "./header";
function WebhookDetailsPage({ params }: Route.ComponentProps) {
// states
@@ -87,7 +89,7 @@ function WebhookDetailsPage({ params }: Route.ComponentProps) {
);
return (
<SettingsContentWrapper>
<SettingsContentWrapper header={<WebhookDetailsWorkspaceSettingsHeader />}>
<PageHead title={pageTitle} />
<DeleteWebhookModal isOpen={deleteWebhookModal} onClose={() => setDeleteWebhookModal(false)} />
<div className="w-full space-y-8 overflow-y-auto">
@@ -0,0 +1,36 @@
import { observer } from "mobx-react";
// plane imports
import { WORKSPACE_SETTINGS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Breadcrumbs } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { SettingsPageHeader } from "@/components/settings/page-header";
import { WORKSPACE_SETTINGS_ICONS } from "@/components/settings/workspace/sidebar/item-icon";
export const WebhooksWorkspaceSettingsHeader = observer(function WebhooksWorkspaceSettingsHeader() {
// translation
const { t } = useTranslation();
// derived values
const settingsDetails = WORKSPACE_SETTINGS.webhooks;
const Icon = WORKSPACE_SETTINGS_ICONS.webhooks;
return (
<SettingsPageHeader
leftItem={
<div className="flex items-center gap-2">
<Breadcrumbs>
<Breadcrumbs.Item
component={
<BreadcrumbLink
label={t(settingsDetails.i18n_label)}
icon={<Icon className="size-4 text-tertiary" />}
/>
}
/>
</Breadcrumbs>
</div>
}
/>
);
});
@@ -4,19 +4,22 @@ import useSWR from "swr";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
// components
import { EmptyStateCompact } from "@plane/propel/empty-state";
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
import { PageHead } from "@/components/core/page-title";
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
import { SettingsHeading } from "@/components/settings/heading";
import { WebhookSettingsLoader } from "@/components/ui/loader/settings/web-hook";
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
import { WebhooksList, CreateWebhookModal } from "@/components/web-hooks";
// hooks
import { useWebhook } from "@/hooks/store/use-webhook";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUserPermissions } from "@/hooks/store/user";
// local imports
import type { Route } from "./+types/page";
import { WebhooksWorkspaceSettingsHeader } from "./header";
function WebhooksListPage({ params }: Route.ComponentProps) {
// states
@@ -53,7 +56,7 @@ function WebhooksListPage({ params }: Route.ComponentProps) {
if (!webhooks) return <WebhookSettingsLoader />;
return (
<SettingsContentWrapper>
<SettingsContentWrapper header={<WebhooksWorkspaceSettingsHeader />}>
<PageHead title={pageTitle} />
<div className="w-full">
<CreateWebhookModal
@@ -68,15 +71,14 @@ function WebhooksListPage({ params }: Route.ComponentProps) {
<SettingsHeading
title={t("workspace_settings.settings.webhooks.title")}
description={t("workspace_settings.settings.webhooks.description")}
button={{
label: t("workspace_settings.settings.webhooks.add_webhook"),
onClick: () => {
setShowCreateWebhookModal(true);
},
}}
control={
<Button variant="primary" size="lg" onClick={() => setShowCreateWebhookModal(true)}>
{t("workspace_settings.settings.webhooks.add_webhook")}
</Button>
}
/>
{Object.keys(webhooks).length > 0 ? (
<div className="flex h-full w-full flex-col">
<div className="mt-4">
<WebhooksList />
</div>
) : (
@@ -1,98 +0,0 @@
import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// plane imports
import { useTranslation } from "@plane/i18n";
// component
import { EmptyStateCompact } from "@plane/propel/empty-state";
import { APITokenService } from "@plane/services";
import { CreateApiTokenModal } from "@/components/api-token/modal/create-token-modal";
import { ApiTokenListItem } from "@/components/api-token/token-list-item";
import { PageHead } from "@/components/core/page-title";
import { SettingsHeading } from "@/components/settings/heading";
import { APITokenSettingsLoader } from "@/components/ui/loader/settings/api-token";
import { API_TOKENS_LIST } from "@/constants/fetch-keys";
// store hooks
import { useWorkspace } from "@/hooks/store/use-workspace";
const apiTokenService = new APITokenService();
function ApiTokensPage() {
// states
const [isCreateTokenModalOpen, setIsCreateTokenModalOpen] = useState(false);
// router
// plane hooks
const { t } = useTranslation();
// store hooks
const { currentWorkspace } = useWorkspace();
const { data: tokens } = useSWR(API_TOKENS_LIST, () => apiTokenService.list());
const pageTitle = currentWorkspace?.name
? `${currentWorkspace.name} - ${t("workspace_settings.settings.api_tokens.title")}`
: undefined;
if (!tokens) {
return <APITokenSettingsLoader />;
}
return (
<div className="w-full">
<PageHead title={pageTitle} />
<CreateApiTokenModal isOpen={isCreateTokenModalOpen} onClose={() => setIsCreateTokenModalOpen(false)} />
<section className="w-full">
{tokens.length > 0 ? (
<>
<SettingsHeading
title={t("account_settings.api_tokens.heading")}
description={t("account_settings.api_tokens.description")}
button={{
label: t("workspace_settings.settings.api_tokens.add_token"),
onClick: () => {
setIsCreateTokenModalOpen(true);
},
}}
/>
<div>
{tokens.map((token) => (
<ApiTokenListItem key={token.id} token={token} />
))}
</div>
</>
) : (
<div className="flex h-full w-full flex-col py-">
<SettingsHeading
title={t("account_settings.api_tokens.heading")}
description={t("account_settings.api_tokens.description")}
button={{
label: t("workspace_settings.settings.api_tokens.add_token"),
onClick: () => {
setIsCreateTokenModalOpen(true);
},
}}
/>
<EmptyStateCompact
assetKey="token"
assetClassName="size-20"
title={t("settings_empty_state.tokens.title")}
description={t("settings_empty_state.tokens.description")}
actions={[
{
label: t("settings_empty_state.tokens.cta_primary"),
onClick: () => {
setIsCreateTokenModalOpen(true);
},
},
]}
align="start"
rootClassName="py-20"
/>
</div>
)}
</section>
</div>
);
}
export default observer(ApiTokensPage);
@@ -1,32 +0,0 @@
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
import { Outlet } from "react-router";
// components
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
import { getProfileActivePath } from "@/components/settings/helper";
import { SettingsMobileNav } from "@/components/settings/mobile";
// local imports
import { ProfileSidebar } from "./sidebar";
function ProfileSettingsLayout() {
// router
const pathname = usePathname();
return (
<>
<SettingsMobileNav hamburgerContent={ProfileSidebar} activePath={getProfileActivePath(pathname) || ""} />
<div className="relative flex h-full w-full">
<div className="hidden md:block">
<ProfileSidebar />
</div>
<div className="w-full h-full overflow-y-scroll md:pt-page-y">
<SettingsContentWrapper>
<Outlet />
</SettingsContentWrapper>
</div>
</div>
</>
);
}
export default observer(ProfileSettingsLayout);
@@ -1,39 +0,0 @@
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
// components
import { PageHead } from "@/components/core/page-title";
import { PreferencesList } from "@/components/preferences/list";
import { LanguageTimezone } from "@/components/profile/preferences/language-timezone";
import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header";
import { SettingsHeading } from "@/components/settings/heading";
// hooks
import { useUserProfile } from "@/hooks/store/user";
const ProfileAppearancePage = observer(() => {
const { t } = useTranslation();
// hooks
const { data: userProfile } = useUserProfile();
if (!userProfile) return <></>;
return (
<>
<PageHead title={`${t("profile.label")} - ${t("preferences")}`} />
<div className="flex flex-col gap-4 w-full">
<div>
<SettingsHeading
title={t("account_settings.preferences.heading")}
description={t("account_settings.preferences.description")}
/>
<PreferencesList />
</div>
<div>
<ProfileSettingContentHeader title={t("language_and_time")} />
<LanguageTimezone />
</div>
</div>
</>
);
});
export default ProfileAppearancePage;
@@ -1,262 +0,0 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
import { Eye, EyeOff } from "lucide-react";
// plane imports
import { E_PASSWORD_STRENGTH } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Input, PasswordStrengthIndicator } from "@plane/ui";
import { getPasswordStrength } from "@plane/utils";
// components
import { PageHead } from "@/components/core/page-title";
import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header";
// helpers
import { authErrorHandler } from "@/helpers/authentication.helper";
import type { EAuthenticationErrorCodes } from "@/helpers/authentication.helper";
// hooks
import { useUser } from "@/hooks/store/user";
// services
import { AuthService } from "@/services/auth.service";
export interface FormValues {
old_password: string;
new_password: string;
confirm_password: string;
}
const defaultValues: FormValues = {
old_password: "",
new_password: "",
confirm_password: "",
};
const authService = new AuthService();
const defaultShowPassword = {
oldPassword: false,
password: false,
confirmPassword: false,
};
function SecurityPage() {
// store
const { data: currentUser, changePassword } = useUser();
// states
const [showPassword, setShowPassword] = useState(defaultShowPassword);
const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false);
const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false);
// use form
const {
control,
handleSubmit,
watch,
formState: { errors, isSubmitting },
reset,
} = useForm<FormValues>({ defaultValues });
// derived values
const oldPassword = watch("old_password");
const password = watch("new_password");
const confirmPassword = watch("confirm_password");
const oldPasswordRequired = !currentUser?.is_password_autoset;
// i18n
const { t } = useTranslation();
const isNewPasswordSameAsOldPassword = oldPassword !== "" && password !== "" && password === oldPassword;
const handleShowPassword = (key: keyof typeof showPassword) =>
setShowPassword((prev) => ({ ...prev, [key]: !prev[key] }));
const handleChangePassword = async (formData: FormValues) => {
const { old_password, new_password } = formData;
try {
const csrfToken = await authService.requestCSRFToken().then((data) => data?.csrf_token);
if (!csrfToken) throw new Error("csrf token not found");
await changePassword(csrfToken, {
...(oldPasswordRequired && { old_password }),
new_password,
});
reset(defaultValues);
setShowPassword(defaultShowPassword);
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("auth.common.password.toast.change_password.success.title"),
message: t("auth.common.password.toast.change_password.success.message"),
});
} catch (error: unknown) {
let errorInfo = undefined;
if (error instanceof Error) {
const err = error as Error & { error_code?: string };
const code = err.error_code?.toString();
errorInfo = code ? authErrorHandler(code as EAuthenticationErrorCodes) : undefined;
}
setToast({
type: TOAST_TYPE.ERROR,
title: errorInfo?.title ?? t("auth.common.password.toast.error.title"),
message:
typeof errorInfo?.message === "string" ? errorInfo.message : t("auth.common.password.toast.error.message"),
});
}
};
const isButtonDisabled =
getPasswordStrength(password) != E_PASSWORD_STRENGTH.STRENGTH_VALID ||
(oldPasswordRequired && oldPassword.trim() === "") ||
password.trim() === "" ||
confirmPassword.trim() === "" ||
password !== confirmPassword ||
password === oldPassword;
const passwordSupport = password.length > 0 &&
getPasswordStrength(password) != E_PASSWORD_STRENGTH.STRENGTH_VALID && (
<PasswordStrengthIndicator password={password} isFocused={isPasswordInputFocused} />
);
const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length;
return (
<>
<PageHead title="Profile - Security" />
<ProfileSettingContentHeader title={t("auth.common.password.change_password.label.default")} />
<form onSubmit={handleSubmit(handleChangePassword)} className="flex flex-col gap-8 w-full mt-8">
<div className="flex flex-col gap-10 w-full">
{oldPasswordRequired && (
<div className="space-y-1">
<h4 className="text-13">{t("auth.common.password.current_password.label")}</h4>
<div className="relative flex items-center rounded-md">
<Controller
control={control}
name="old_password"
rules={{
required: t("common.errors.required"),
}}
render={({ field: { value, onChange } }) => (
<Input
id="old_password"
type={showPassword?.oldPassword ? "text" : "password"}
value={value}
onChange={onChange}
placeholder={t("old_password")}
className="w-full"
hasError={Boolean(errors.old_password)}
/>
)}
/>
{showPassword?.oldPassword ? (
<EyeOff
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
onClick={() => handleShowPassword("oldPassword")}
/>
) : (
<Eye
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
onClick={() => handleShowPassword("oldPassword")}
/>
)}
</div>
{errors.old_password && (
<span className="text-11 text-danger-primary">{errors.old_password.message}</span>
)}
</div>
)}
<div className="space-y-1">
<h4 className="text-13">{t("auth.common.password.new_password.label")}</h4>
<div className="relative flex items-center rounded-md">
<Controller
control={control}
name="new_password"
rules={{
required: t("common.errors.required"),
}}
render={({ field: { value, onChange } }) => (
<Input
id="new_password"
type={showPassword?.password ? "text" : "password"}
value={value}
placeholder={t("auth.common.password.new_password.placeholder")}
onChange={onChange}
className="w-full"
hasError={Boolean(errors.new_password)}
onFocus={() => setIsPasswordInputFocused(true)}
onBlur={() => setIsPasswordInputFocused(false)}
/>
)}
/>
{showPassword?.password ? (
<EyeOff
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
onClick={() => handleShowPassword("password")}
/>
) : (
<Eye
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
onClick={() => handleShowPassword("password")}
/>
)}
</div>
{passwordSupport}
{isNewPasswordSameAsOldPassword && !isPasswordInputFocused && (
<span className="text-11 text-danger-primary">
{t("new_password_must_be_different_from_old_password")}
</span>
)}
</div>
<div className="space-y-1">
<h4 className="text-13">{t("auth.common.password.confirm_password.label")}</h4>
<div className="relative flex items-center rounded-md">
<Controller
control={control}
name="confirm_password"
rules={{
required: t("common.errors.required"),
}}
render={({ field: { value, onChange } }) => (
<Input
id="confirm_password"
type={showPassword?.confirmPassword ? "text" : "password"}
placeholder={t("auth.common.password.confirm_password.placeholder")}
value={value}
onChange={onChange}
className="w-full"
hasError={Boolean(errors.confirm_password)}
onFocus={() => setIsRetryPasswordInputFocused(true)}
onBlur={() => setIsRetryPasswordInputFocused(false)}
/>
)}
/>
{showPassword?.confirmPassword ? (
<EyeOff
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
onClick={() => handleShowPassword("confirmPassword")}
/>
) : (
<Eye
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
onClick={() => handleShowPassword("confirmPassword")}
/>
)}
</div>
{!!confirmPassword && password !== confirmPassword && renderPasswordMatchError && (
<span className="text-13 text-danger-primary">{t("auth.common.password.errors.match")}</span>
)}
</div>
</div>
<div className="flex items-center justify-between py-2">
<Button variant="primary" type="submit" loading={isSubmitting} disabled={isButtonDisabled}>
{isSubmitting
? `${t("auth.common.password.change_password.label.submitting")}`
: t("auth.common.password.change_password.label.default")}
</Button>
</div>
</form>
</>
);
}
export default observer(SecurityPage);
@@ -1,76 +0,0 @@
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import { CircleUser, Activity, Bell, CircleUserRound, KeyRound, Settings2, Blocks } from "lucide-react";
// plane imports
import { GROUPED_PROFILE_SETTINGS, PROFILE_SETTINGS_CATEGORIES } from "@plane/constants";
import { LockIcon } from "@plane/propel/icons";
import { getFileURL } from "@plane/utils";
// components
import { SettingsSidebar } from "@/components/settings/sidebar";
// hooks
import { useUser } from "@/hooks/store/user";
const ICONS = {
profile: CircleUser,
security: LockIcon,
activity: Activity,
preferences: Settings2,
notifications: Bell,
"api-tokens": KeyRound,
connections: Blocks,
};
export function ProjectActionIcons({ type, size, className }: { type: string; size?: number; className?: string }) {
if (type === undefined) return null;
const Icon = ICONS[type as keyof typeof ICONS];
if (!Icon) return null;
return <Icon size={size} className={className} strokeWidth={2} />;
}
type TProfileSidebarProps = {
isMobile?: boolean;
};
export const ProfileSidebar = observer(function ProfileSidebar(props: TProfileSidebarProps) {
const { isMobile = false } = props;
// router
const pathname = usePathname();
const { workspaceSlug } = useParams();
// store hooks
const { data: currentUser } = useUser();
return (
<SettingsSidebar
isMobile={isMobile}
categories={PROFILE_SETTINGS_CATEGORIES}
groupedSettings={GROUPED_PROFILE_SETTINGS}
workspaceSlug={workspaceSlug?.toString() ?? ""}
isActive={(data: { href: string }) => pathname === `/${workspaceSlug}${data.href}/`}
customHeader={
<div className="flex items-center gap-2">
<div className="flex-shrink-0">
{!currentUser?.avatar_url || currentUser?.avatar_url === "" ? (
<div className="h-8 w-8 rounded-full">
<CircleUserRound className="h-full w-full text-secondary" />
</div>
) : (
<div className="relative h-8 w-8 overflow-hidden">
<img
src={getFileURL(currentUser?.avatar_url)}
className="absolute left-0 top-0 h-full w-full rounded-lg object-cover"
alt={currentUser?.display_name}
/>
</div>
)}
</div>
<div className="w-full overflow-hidden">
<div className="text-14 font-medium text-secondary truncate">{currentUser?.display_name}</div>
<div className="text-13 text-tertiary truncate">{currentUser?.email}</div>
</div>
</div>
}
actionIcons={ProjectActionIcons}
shouldRender
/>
);
});
@@ -0,0 +1,36 @@
import { observer } from "mobx-react";
// plane imports
import { PROJECT_SETTINGS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Breadcrumbs } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { SettingsPageHeader } from "@/components/settings/page-header";
import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon";
export const AutomationsProjectSettingsHeader = observer(function AutomationsProjectSettingsHeader() {
// translation
const { t } = useTranslation();
// derived values
const settingsDetails = PROJECT_SETTINGS.automations;
const Icon = PROJECT_SETTINGS_ICONS.automations;
return (
<SettingsPageHeader
leftItem={
<div className="flex items-center gap-2">
<Breadcrumbs>
<Breadcrumbs.Item
component={
<BreadcrumbLink
label={t(settingsDetails.i18n_label)}
icon={<Icon className="size-4 text-tertiary" />}
/>
}
/>
</Breadcrumbs>
</div>
}
/>
);
});
@@ -1,21 +1,22 @@
import { observer } from "mobx-react";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IProject } from "@plane/types";
// ui
// components
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
import { AutoArchiveAutomation, AutoCloseAutomation } from "@/components/automation";
import { PageHead } from "@/components/core/page-title";
// hooks
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
import { SettingsHeading } from "@/components/settings/heading";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
// plane web imports
import { CustomAutomationsRoot } from "@/plane-web/components/automations/root";
// local imports
import type { Route } from "./+types/page";
import { AutomationsProjectSettingsHeader } from "./header";
function AutomationSettingsPage({ params }: Route.ComponentProps) {
// router
@@ -51,15 +52,17 @@ function AutomationSettingsPage({ params }: Route.ComponentProps) {
}
return (
<SettingsContentWrapper size="lg">
<SettingsContentWrapper header={<AutomationsProjectSettingsHeader />} hugging>
<PageHead title={pageTitle} />
<section className={`w-full ${canPerformProjectAdminActions ? "" : "opacity-60"}`}>
<SettingsHeading
title={t("project_settings.automations.heading")}
description={t("project_settings.automations.description")}
/>
<AutoArchiveAutomation handleChange={handleChange} />
<AutoCloseAutomation handleChange={handleChange} />
<div className="mt-6">
<AutoArchiveAutomation handleChange={handleChange} />
<AutoCloseAutomation handleChange={handleChange} />
</div>
</section>
<CustomAutomationsRoot projectId={projectId} workspaceSlug={workspaceSlug} />
</SettingsContentWrapper>
@@ -0,0 +1,36 @@
import { observer } from "mobx-react";
// plane imports
import { PROJECT_SETTINGS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Breadcrumbs } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { SettingsPageHeader } from "@/components/settings/page-header";
import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon";
export const EstimatesProjectSettingsHeader = observer(function EstimatesProjectSettingsHeader() {
// translation
const { t } = useTranslation();
// derived values
const settingsDetails = PROJECT_SETTINGS.estimates;
const Icon = PROJECT_SETTINGS_ICONS.estimates;
return (
<SettingsPageHeader
leftItem={
<div className="flex items-center gap-2">
<Breadcrumbs>
<Breadcrumbs.Item
component={
<BreadcrumbLink
label={t(settingsDetails.i18n_label)}
icon={<Icon className="size-4 text-tertiary" />}
/>
}
/>
</Breadcrumbs>
</div>
}
/>
);
});
@@ -4,11 +4,13 @@ import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
import { PageHead } from "@/components/core/page-title";
import { EstimateRoot } from "@/components/estimates";
// hooks
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
// local imports
import type { Route } from "./+types/page";
import { EstimatesProjectSettingsHeader } from "./header";
function EstimatesSettingsPage({ params }: Route.ComponentProps) {
const { workspaceSlug, projectId } = params;
@@ -25,7 +27,7 @@ function EstimatesSettingsPage({ params }: Route.ComponentProps) {
}
return (
<SettingsContentWrapper>
<SettingsContentWrapper header={<EstimatesProjectSettingsHeader />}>
<PageHead title={pageTitle} />
<div className={`w-full ${canPerformProjectAdminActions ? "" : "pointer-events-none opacity-60"}`}>
<EstimateRoot workspaceSlug={workspaceSlug} projectId={projectId} isAdmin={canPerformProjectAdminActions} />
@@ -0,0 +1,36 @@
import { observer } from "mobx-react";
// plane imports
import { PROJECT_SETTINGS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Breadcrumbs } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { SettingsPageHeader } from "@/components/settings/page-header";
import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon";
export const FeaturesCyclesProjectSettingsHeader = observer(function FeaturesCyclesProjectSettingsHeader() {
// translation
const { t } = useTranslation();
// derived values
const settingsDetails = PROJECT_SETTINGS.features_cycles;
const Icon = PROJECT_SETTINGS_ICONS.features_cycles;
return (
<SettingsPageHeader
leftItem={
<div className="flex items-center gap-2">
<Breadcrumbs>
<Breadcrumbs.Item
component={
<BreadcrumbLink
label={t(settingsDetails.i18n_label)}
icon={<Icon className="size-4 text-tertiary" />}
/>
}
/>
</Breadcrumbs>
</div>
}
/>
);
});
@@ -0,0 +1,58 @@
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
// components
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
import { PageHead } from "@/components/core/page-title";
import { ProjectSettingsFeatureControlItem } from "@/components/settings/project/content/feature-control-item";
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
// local imports
import type { Route } from "./+types/page";
import { FeaturesCyclesProjectSettingsHeader } from "./header";
import { SettingsHeading } from "@/components/settings/heading";
function FeaturesCyclesSettingsPage({ params }: Route.ComponentProps) {
const { workspaceSlug, projectId } = params;
// store hooks
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
const { currentProjectDetails } = useProject();
// translation
const { t } = useTranslation();
// derived values
const pageTitle = currentProjectDetails?.name
? `${currentProjectDetails?.name} settings - ${t("project_settings.features.cycles.short_title")}`
: undefined;
const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
if (workspaceUserInfo && !canPerformProjectAdminActions) {
return <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
}
return (
<SettingsContentWrapper header={<FeaturesCyclesProjectSettingsHeader />}>
<PageHead title={pageTitle} />
<section className="w-full">
<SettingsHeading
title={t("project_settings.features.cycles.title")}
description={t("project_settings.features.cycles.description")}
/>
<div className="mt-7">
<ProjectSettingsFeatureControlItem
title={t("project_settings.features.cycles.toggle_title")}
description={t("project_settings.features.cycles.toggle_description")}
featureProperty="cycle_view"
projectId={projectId}
value={!!currentProjectDetails?.cycle_view}
workspaceSlug={workspaceSlug}
/>
</div>
</section>
</SettingsContentWrapper>
);
}
export default observer(FeaturesCyclesSettingsPage);
@@ -0,0 +1,36 @@
import { observer } from "mobx-react";
// plane imports
import { PROJECT_SETTINGS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Breadcrumbs } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { SettingsPageHeader } from "@/components/settings/page-header";
import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon";
export const FeaturesIntakeProjectSettingsHeader = observer(function FeaturesIntakeProjectSettingsHeader() {
// translation
const { t } = useTranslation();
// derived values
const settingsDetails = PROJECT_SETTINGS.features_intake;
const Icon = PROJECT_SETTINGS_ICONS.features_intake;
return (
<SettingsPageHeader
leftItem={
<div className="flex items-center gap-2">
<Breadcrumbs>
<Breadcrumbs.Item
component={
<BreadcrumbLink
label={t(settingsDetails.i18n_label)}
icon={<Icon className="size-4 text-tertiary" />}
/>
}
/>
</Breadcrumbs>
</div>
}
/>
);
});
@@ -0,0 +1,58 @@
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
// components
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
import { PageHead } from "@/components/core/page-title";
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
import { SettingsHeading } from "@/components/settings/heading";
import { ProjectSettingsFeatureControlItem } from "@/components/settings/project/content/feature-control-item";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
// local imports
import type { Route } from "./+types/page";
import { FeaturesIntakeProjectSettingsHeader } from "./header";
function FeaturesIntakeSettingsPage({ params }: Route.ComponentProps) {
const { workspaceSlug, projectId } = params;
// store hooks
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
const { currentProjectDetails } = useProject();
// translation
const { t } = useTranslation();
// derived values
const pageTitle = currentProjectDetails?.name
? `${currentProjectDetails?.name} settings - ${t("project_settings.features.intake.short_title")}`
: undefined;
const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
if (workspaceUserInfo && !canPerformProjectAdminActions) {
return <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
}
return (
<SettingsContentWrapper header={<FeaturesIntakeProjectSettingsHeader />}>
<PageHead title={pageTitle} />
<section className="w-full">
<SettingsHeading
title={t("project_settings.features.intake.title")}
description={t("project_settings.features.intake.description")}
/>
<div className="mt-7">
<ProjectSettingsFeatureControlItem
title={t("project_settings.features.intake.toggle_title")}
description={t("project_settings.features.intake.toggle_description")}
featureProperty="inbox_view"
projectId={projectId}
value={!!currentProjectDetails?.inbox_view}
workspaceSlug={workspaceSlug}
/>
</div>
</section>
</SettingsContentWrapper>
);
}
export default observer(FeaturesIntakeSettingsPage);
@@ -0,0 +1,36 @@
import { observer } from "mobx-react";
// plane imports
import { PROJECT_SETTINGS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Breadcrumbs } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { SettingsPageHeader } from "@/components/settings/page-header";
import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon";
export const FeaturesModulesProjectSettingsHeader = observer(function FeaturesModulesProjectSettingsHeader() {
// translation
const { t } = useTranslation();
// derived values
const settingsDetails = PROJECT_SETTINGS.features_modules;
const Icon = PROJECT_SETTINGS_ICONS.features_modules;
return (
<SettingsPageHeader
leftItem={
<div className="flex items-center gap-2">
<Breadcrumbs>
<Breadcrumbs.Item
component={
<BreadcrumbLink
label={t(settingsDetails.i18n_label)}
icon={<Icon className="size-4 text-tertiary" />}
/>
}
/>
</Breadcrumbs>
</div>
}
/>
);
});
@@ -0,0 +1,58 @@
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
// components
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
import { PageHead } from "@/components/core/page-title";
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
import { SettingsHeading } from "@/components/settings/heading";
import { ProjectSettingsFeatureControlItem } from "@/components/settings/project/content/feature-control-item";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
// local imports
import type { Route } from "./+types/page";
import { FeaturesModulesProjectSettingsHeader } from "./header";
function FeaturesModulesSettingsPage({ params }: Route.ComponentProps) {
const { workspaceSlug, projectId } = params;
// store hooks
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
const { currentProjectDetails } = useProject();
// translation
const { t } = useTranslation();
// derived values
const pageTitle = currentProjectDetails?.name
? `${currentProjectDetails?.name} settings - ${t("project_settings.features.modules.short_title")}`
: undefined;
const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
if (workspaceUserInfo && !canPerformProjectAdminActions) {
return <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
}
return (
<SettingsContentWrapper header={<FeaturesModulesProjectSettingsHeader />}>
<PageHead title={pageTitle} />
<section className="w-full">
<SettingsHeading
title={t("project_settings.features.modules.title")}
description={t("project_settings.features.modules.description")}
/>
<div className="mt-7">
<ProjectSettingsFeatureControlItem
title={t("project_settings.features.modules.toggle_title")}
description={t("project_settings.features.modules.toggle_description")}
featureProperty="module_view"
projectId={projectId}
value={!!currentProjectDetails?.module_view}
workspaceSlug={workspaceSlug}
/>
</div>
</section>
</SettingsContentWrapper>
);
}
export default observer(FeaturesModulesSettingsPage);
@@ -1,41 +0,0 @@
import { observer } from "mobx-react";
// components
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
import { PageHead } from "@/components/core/page-title";
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { ProjectFeaturesList } from "@/plane-web/components/projects/settings/features-list";
import type { Route } from "./+types/page";
function FeaturesSettingsPage({ params }: Route.ComponentProps) {
const { workspaceSlug, projectId } = params;
// store
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
const { currentProjectDetails } = useProject();
// derived values
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Features` : undefined;
const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
if (workspaceUserInfo && !canPerformProjectAdminActions) {
return <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
}
return (
<SettingsContentWrapper>
<PageHead title={pageTitle} />
<section className={`w-full ${canPerformProjectAdminActions ? "" : "opacity-60"}`}>
<ProjectFeaturesList
workspaceSlug={workspaceSlug}
projectId={projectId}
isAdmin={canPerformProjectAdminActions}
/>
</section>
</SettingsContentWrapper>
);
}
export default observer(FeaturesSettingsPage);
@@ -0,0 +1,36 @@
import { observer } from "mobx-react";
// plane imports
import { PROJECT_SETTINGS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Breadcrumbs } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { SettingsPageHeader } from "@/components/settings/page-header";
import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon";
export const FeaturesPagesProjectSettingsHeader = observer(function FeaturesPagesProjectSettingsHeader() {
// translation
const { t } = useTranslation();
// derived values
const settingsDetails = PROJECT_SETTINGS.features_pages;
const Icon = PROJECT_SETTINGS_ICONS.features_pages;
return (
<SettingsPageHeader
leftItem={
<div className="flex items-center gap-2">
<Breadcrumbs>
<Breadcrumbs.Item
component={
<BreadcrumbLink
label={t(settingsDetails.i18n_label)}
icon={<Icon className="size-4 text-tertiary" />}
/>
}
/>
</Breadcrumbs>
</div>
}
/>
);
});
@@ -0,0 +1,58 @@
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
// components
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
import { PageHead } from "@/components/core/page-title";
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
import { SettingsHeading } from "@/components/settings/heading";
import { ProjectSettingsFeatureControlItem } from "@/components/settings/project/content/feature-control-item";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
// local imports
import type { Route } from "./+types/page";
import { FeaturesPagesProjectSettingsHeader } from "./header";
function FeaturesPagesSettingsPage({ params }: Route.ComponentProps) {
const { workspaceSlug, projectId } = params;
// store hooks
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
const { currentProjectDetails } = useProject();
// translation
const { t } = useTranslation();
// derived values
const pageTitle = currentProjectDetails?.name
? `${currentProjectDetails?.name} settings - ${t("project_settings.features.pages.short_title")}`
: undefined;
const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
if (workspaceUserInfo && !canPerformProjectAdminActions) {
return <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
}
return (
<SettingsContentWrapper header={<FeaturesPagesProjectSettingsHeader />}>
<PageHead title={pageTitle} />
<section className="w-full">
<SettingsHeading
title={t("project_settings.features.pages.title")}
description={t("project_settings.features.pages.description")}
/>
<div className="mt-7">
<ProjectSettingsFeatureControlItem
title={t("project_settings.features.pages.toggle_title")}
description={t("project_settings.features.pages.toggle_description")}
featureProperty="page_view"
projectId={projectId}
value={!!currentProjectDetails?.page_view}
workspaceSlug={workspaceSlug}
/>
</div>
</section>
</SettingsContentWrapper>
);
}
export default observer(FeaturesPagesSettingsPage);
@@ -0,0 +1,36 @@
import { observer } from "mobx-react";
// plane imports
import { PROJECT_SETTINGS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Breadcrumbs } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { SettingsPageHeader } from "@/components/settings/page-header";
import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon";
export const FeaturesViewsProjectSettingsHeader = observer(function FeaturesViewsProjectSettingsHeader() {
// translation
const { t } = useTranslation();
// derived values
const settingsDetails = PROJECT_SETTINGS.features_views;
const Icon = PROJECT_SETTINGS_ICONS.features_views;
return (
<SettingsPageHeader
leftItem={
<div className="flex items-center gap-2">
<Breadcrumbs>
<Breadcrumbs.Item
component={
<BreadcrumbLink
label={t(settingsDetails.i18n_label)}
icon={<Icon className="size-4 text-tertiary" />}
/>
}
/>
</Breadcrumbs>
</div>
}
/>
);
});
@@ -0,0 +1,58 @@
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
// components
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
import { PageHead } from "@/components/core/page-title";
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
import { SettingsHeading } from "@/components/settings/heading";
import { ProjectSettingsFeatureControlItem } from "@/components/settings/project/content/feature-control-item";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
// local imports
import type { Route } from "./+types/page";
import { FeaturesViewsProjectSettingsHeader } from "./header";
function FeaturesViewsSettingsPage({ params }: Route.ComponentProps) {
const { workspaceSlug, projectId } = params;
// store hooks
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
const { currentProjectDetails } = useProject();
// translation
const { t } = useTranslation();
// derived values
const pageTitle = currentProjectDetails?.name
? `${currentProjectDetails?.name} settings - ${t("project_settings.features.views.short_title")}`
: undefined;
const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
if (workspaceUserInfo && !canPerformProjectAdminActions) {
return <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
}
return (
<SettingsContentWrapper header={<FeaturesViewsProjectSettingsHeader />}>
<PageHead title={pageTitle} />
<section className="w-full">
<SettingsHeading
title={t("project_settings.features.views.title")}
description={t("project_settings.features.views.description")}
/>
<div className="mt-7">
<ProjectSettingsFeatureControlItem
title={t("project_settings.features.views.toggle_title")}
description={t("project_settings.features.views.toggle_description")}
featureProperty="issue_views_view"
projectId={projectId}
value={!!currentProjectDetails?.issue_views_view}
workspaceSlug={workspaceSlug}
/>
</div>
</section>
</SettingsContentWrapper>
);
}
export default observer(FeaturesViewsSettingsPage);
@@ -0,0 +1,36 @@
import { observer } from "mobx-react";
// plane imports
import { PROJECT_SETTINGS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Breadcrumbs } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { SettingsPageHeader } from "@/components/settings/page-header";
import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon";
export const GeneralProjectSettingsHeader = observer(function GeneralProjectSettingsHeader() {
// translation
const { t } = useTranslation();
// derived values
const settingsDetails = PROJECT_SETTINGS.general;
const Icon = PROJECT_SETTINGS_ICONS.general;
return (
<SettingsPageHeader
leftItem={
<div className="flex items-center gap-2">
<Breadcrumbs>
<Breadcrumbs.Item
component={
<BreadcrumbLink
label={t(settingsDetails.i18n_label)}
icon={<Icon className="size-4 text-tertiary" />}
/>
}
/>
</Breadcrumbs>
</div>
}
/>
);
});
@@ -0,0 +1,36 @@
import { observer } from "mobx-react";
// plane imports
import { PROJECT_SETTINGS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Breadcrumbs } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { SettingsPageHeader } from "@/components/settings/page-header";
import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon";
export const LabelsProjectSettingsHeader = observer(function LabelsProjectSettingsHeader() {
// translation
const { t } = useTranslation();
// derived values
const settingsDetails = PROJECT_SETTINGS.labels;
const Icon = PROJECT_SETTINGS_ICONS.labels;
return (
<SettingsPageHeader
leftItem={
<div className="flex items-center gap-2">
<Breadcrumbs>
<Breadcrumbs.Item
component={
<BreadcrumbLink
label={t(settingsDetails.i18n_label)}
icon={<Icon className="size-4 text-tertiary" />}
/>
}
/>
</Breadcrumbs>
</div>
}
/>
);
});
@@ -7,10 +7,12 @@ import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
import { PageHead } from "@/components/core/page-title";
import { ProjectSettingsLabelList } from "@/components/labels";
// hooks
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
// local imports
import { LabelsProjectSettingsHeader } from "./header";
function LabelsSettingsPage() {
// store hooks
@@ -45,9 +47,9 @@ function LabelsSettingsPage() {
}
return (
<SettingsContentWrapper>
<SettingsContentWrapper header={<LabelsProjectSettingsHeader />}>
<PageHead title={pageTitle} />
<div ref={scrollableContainerRef} className="h-full w-full gap-10">
<div ref={scrollableContainerRef} className="size-full">
<ProjectSettingsLabelList />
</div>
</SettingsContentWrapper>
@@ -3,12 +3,12 @@ import { usePathname } from "next/navigation";
import { Outlet } from "react-router";
// components
import { getProjectActivePath } from "@/components/settings/helper";
import { SettingsMobileNav } from "@/components/settings/mobile";
import { ProjectSettingsSidebar } from "@/components/settings/project/sidebar";
import { SettingsMobileNav } from "@/components/settings/mobile/nav";
// plane web imports
import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper";
// types
import type { Route } from "./+types/layout";
import { ProjectSettingsSidebarRoot } from "@/components/settings/project/sidebar";
function ProjectDetailSettingsLayout({ params }: Route.ComponentProps) {
const { workspaceSlug, projectId } = params;
@@ -17,14 +17,19 @@ function ProjectDetailSettingsLayout({ params }: Route.ComponentProps) {
return (
<>
<SettingsMobileNav hamburgerContent={ProjectSettingsSidebar} activePath={getProjectActivePath(pathname) || ""} />
<div className="relative flex h-full w-full">
<div className="hidden md:block">{projectId && <ProjectSettingsSidebar />}</div>
<ProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={projectId}>
<div className="w-full h-full overflow-y-scroll md:pt-page-y">
<Outlet />
<SettingsMobileNav
hamburgerContent={(props) => <ProjectSettingsSidebarRoot {...props} projectId={projectId} />}
activePath={getProjectActivePath(pathname) || ""}
/>
<div className="inset-y-0 flex flex-row w-full h-full">
<div className="relative flex size-full">
<div className="shrink-0 h-full hidden md:block">
<ProjectSettingsSidebarRoot projectId={projectId} />
</div>
</ProjectAuthWrapper>
<ProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={projectId}>
<Outlet />
</ProjectAuthWrapper>
</div>
</div>
</>
);
@@ -0,0 +1,36 @@
import { observer } from "mobx-react";
// plane imports
import { PROJECT_SETTINGS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Breadcrumbs } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { SettingsPageHeader } from "@/components/settings/page-header";
import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon";
export const MembersProjectSettingsHeader = observer(function MembersProjectSettingsHeader() {
// translation
const { t } = useTranslation();
// derived values
const settingsDetails = PROJECT_SETTINGS.members;
const Icon = PROJECT_SETTINGS_ICONS.members;
return (
<SettingsPageHeader
leftItem={
<div className="flex items-center gap-2">
<Breadcrumbs>
<Breadcrumbs.Item
component={
<BreadcrumbLink
label={t(settingsDetails.i18n_label)}
icon={<Icon className="size-4 text-tertiary" />}
/>
}
/>
</Breadcrumbs>
</div>
}
/>
);
});
@@ -5,17 +5,19 @@ import { useTranslation } from "@plane/i18n";
// components
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
import { PageHead } from "@/components/core/page-title";
// hooks
import { ProjectMemberList } from "@/components/project/member-list";
import { ProjectSettingsMemberDefaults } from "@/components/project/project-settings-member-defaults";
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
import { SettingsHeading } from "@/components/settings/heading";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
// plane web imports
import { ProjectTeamspaceList } from "@/plane-web/components/projects/teamspaces/teamspace-list";
import { getProjectSettingsPageLabelI18nKey } from "@/plane-web/helpers/project-settings";
// local imports
import type { Route } from "./+types/page";
import { MembersProjectSettingsHeader } from "./header";
function MembersSettingsPage({ params }: Route.ComponentProps) {
// router
@@ -39,7 +41,7 @@ function MembersSettingsPage({ params }: Route.ComponentProps) {
}
return (
<SettingsContentWrapper size="lg">
<SettingsContentWrapper header={<MembersProjectSettingsHeader />} hugging>
<PageHead title={pageTitle} />
<SettingsHeading title={t(getProjectSettingsPageLabelI18nKey("members", "common.members"))} />
<ProjectSettingsMemberDefaults projectId={projectId} workspaceSlug={workspaceSlug} />
@@ -1,25 +1,20 @@
import { useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
// components
import { PageHead } from "@/components/core/page-title";
import { DeleteProjectModal } from "@/components/project/delete-project-modal";
import { ProjectDetailsForm } from "@/components/project/form";
import { ProjectDetailsFormLoader } from "@/components/project/form-loader";
import { ArchiveRestoreProjectModal } from "@/components/project/settings/archive-project/archive-restore-modal";
import { ArchiveProjectSelection } from "@/components/project/settings/archive-project/selection";
import { DeleteProjectSection } from "@/components/project/settings/delete-project-section";
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
// local imports
import type { Route } from "./+types/page";
import { GeneralProjectSettingsHeader } from "./header";
import { GeneralProjectSettingsControlSection } from "@/components/project/settings/control-section";
function ProjectSettingsPage({ params }: Route.ComponentProps) {
// states
const [selectProject, setSelectedProject] = useState<string | null>(null);
const [archiveProject, setArchiveProject] = useState<boolean>(false);
// router
const { workspaceSlug, projectId } = params;
// store hooks
@@ -31,25 +26,8 @@ function ProjectSettingsPage({ params }: Route.ComponentProps) {
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - General Settings` : undefined;
return (
<SettingsContentWrapper>
<SettingsContentWrapper header={<GeneralProjectSettingsHeader />}>
<PageHead title={pageTitle} />
{currentProjectDetails && (
<>
<ArchiveRestoreProjectModal
workspaceSlug={workspaceSlug}
projectId={projectId}
isOpen={archiveProject}
onClose={() => setArchiveProject(false)}
archive
/>
<DeleteProjectModal
project={currentProjectDetails}
isOpen={Boolean(selectProject)}
onClose={() => setSelectedProject(null)}
/>
</>
)}
<div className={`w-full ${isAdmin ? "" : "opacity-60"}`}>
{currentProjectDetails ? (
<ProjectDetailsForm
@@ -61,19 +39,7 @@ function ProjectSettingsPage({ params }: Route.ComponentProps) {
) : (
<ProjectDetailsFormLoader />
)}
{isAdmin && currentProjectDetails && (
<>
<ArchiveProjectSelection
projectDetails={currentProjectDetails}
handleArchive={() => setArchiveProject(true)}
/>
<DeleteProjectSection
projectDetails={currentProjectDetails}
handleDelete={() => setSelectedProject(currentProjectDetails.id ?? null)}
/>
</>
)}
{isAdmin && <GeneralProjectSettingsControlSection projectId={projectId} />}
</div>
</SettingsContentWrapper>
);
@@ -0,0 +1,36 @@
import { observer } from "mobx-react";
// plane imports
import { PROJECT_SETTINGS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Breadcrumbs } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { SettingsPageHeader } from "@/components/settings/page-header";
import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon";
export const StatesProjectSettingsHeader = observer(function StatesProjectSettingsHeader() {
// translation
const { t } = useTranslation();
// derived values
const settingsDetails = PROJECT_SETTINGS.states;
const Icon = PROJECT_SETTINGS_ICONS.states;
return (
<SettingsPageHeader
leftItem={
<div className="flex items-center gap-2">
<Breadcrumbs>
<Breadcrumbs.Item
component={
<BreadcrumbLink
label={t(settingsDetails.i18n_label)}
icon={<Icon className="size-4 text-tertiary" />}
/>
}
/>
</Breadcrumbs>
</div>
}
/>
);
});
@@ -5,12 +5,14 @@ import { useTranslation } from "@plane/i18n";
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
import { PageHead } from "@/components/core/page-title";
import { ProjectStateRoot } from "@/components/project-states";
// hook
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
import { SettingsHeading } from "@/components/settings/heading";
// hook
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
// local imports
import type { Route } from "./+types/page";
import { StatesProjectSettingsHeader } from "./header";
function StatesSettingsPage({ params }: Route.ComponentProps) {
const { workspaceSlug, projectId } = params;
@@ -33,14 +35,16 @@ function StatesSettingsPage({ params }: Route.ComponentProps) {
}
return (
<SettingsContentWrapper>
<SettingsContentWrapper header={<StatesProjectSettingsHeader />}>
<PageHead title={pageTitle} />
<div className="w-full">
<SettingsHeading
title={t("project_settings.states.heading")}
description={t("project_settings.states.description")}
/>
<ProjectStateRoot workspaceSlug={workspaceSlug} projectId={projectId} />
<div className="mt-6">
<ProjectStateRoot workspaceSlug={workspaceSlug} projectId={projectId} />
</div>
</div>
</SettingsContentWrapper>
);
@@ -2,14 +2,19 @@ import { Outlet } from "react-router";
import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper";
import { WorkspaceContentWrapper } from "@/plane-web/components/workspace/content-wrapper";
import { AppRailVisibilityProvider } from "@/plane-web/hooks/app-rail";
import { GlobalModals } from "@/plane-web/components/common/modal/global";
import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper";
import type { Route } from "./+types/layout";
export default function WorkspaceLayout(props: Route.ComponentProps) {
const { workspaceSlug } = props.params;
export default function WorkspaceLayout() {
return (
<AuthenticationWrapper>
<WorkspaceAuthWrapper>
<AppRailVisibilityProvider>
<WorkspaceContentWrapper>
<GlobalModals workspaceSlug={workspaceSlug} />
<Outlet />
</WorkspaceContentWrapper>
</AppRailVisibilityProvider>
@@ -1,83 +0,0 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { useTheme } from "next-themes";
// plane imports
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
// assets
import darkActivityAsset from "@/app/assets/empty-state/profile/activity-dark.webp?url";
import lightActivityAsset from "@/app/assets/empty-state/profile/activity-light.webp?url";
// components
import { PageHead } from "@/components/core/page-title";
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
import { ProfileActivityListPage } from "@/components/profile/activity/profile-activity-list";
import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header";
import { ProfileSettingContentWrapper } from "@/components/profile/profile-setting-content-wrapper";
const PER_PAGE = 100;
function ProfileActivityPage() {
// states
const [pageCount, setPageCount] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [resultsCount, setResultsCount] = useState(0);
const [isEmpty, setIsEmpty] = useState(false);
// theme hook
const { resolvedTheme } = useTheme();
// plane hooks
const { t } = useTranslation();
// derived values
const resolvedPath = resolvedTheme === "light" ? lightActivityAsset : darkActivityAsset;
const updateTotalPages = (count: number) => setTotalPages(count);
const updateResultsCount = (count: number) => setResultsCount(count);
const updateEmptyState = (isEmpty: boolean) => setIsEmpty(isEmpty);
const handleLoadMore = () => setPageCount((prev) => prev + 1);
const activityPages: React.ReactNode[] = [];
for (let i = 0; i < pageCount; i++)
activityPages.push(
<ProfileActivityListPage
key={i}
cursor={`${PER_PAGE}:${i}:0`}
perPage={PER_PAGE}
updateResultsCount={updateResultsCount}
updateTotalPages={updateTotalPages}
updateEmptyState={updateEmptyState}
/>
);
const isLoadMoreVisible = pageCount < totalPages && resultsCount !== 0;
if (isEmpty) {
return (
<DetailedEmptyState
title={t("profile.empty_state.activity.title")}
description={t("profile.empty_state.activity.description")}
assetPath={resolvedPath}
/>
);
}
return (
<>
<PageHead title="Profile - Activity" />
<ProfileSettingContentWrapper>
<ProfileSettingContentHeader title={t("activity")} />
{activityPages}
{isLoadMoreVisible && (
<div className="flex w-full items-center justify-center text-11">
<Button variant="secondary" onClick={handleLoadMore}>
{t("load_more")}
</Button>
</div>
)}
</ProfileSettingContentWrapper>
</>
);
}
export default observer(ProfileActivityPage);
@@ -1,101 +0,0 @@
import { useCallback, useMemo } from "react";
import { observer } from "mobx-react";
import { useTheme } from "next-themes";
// plane imports
import type { I_THEME_OPTION } from "@plane/constants";
import { THEME_OPTIONS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { setPromiseToast } from "@plane/propel/toast";
import { applyCustomTheme } from "@plane/utils";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
import { PageHead } from "@/components/core/page-title";
import { CustomThemeSelector } from "@/components/core/theme/custom-theme-selector";
import { ThemeSwitch } from "@/components/core/theme/theme-switch";
import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header";
import { ProfileSettingContentWrapper } from "@/components/profile/profile-setting-content-wrapper";
// hooks
import { useUserProfile } from "@/hooks/store/user";
function ProfileAppearancePage() {
// store hooks
const { data: userProfile, updateUserTheme } = useUserProfile();
// theme
const { setTheme } = useTheme();
// translation
const { t } = useTranslation();
// derived values
const currentTheme = useMemo(() => {
const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile?.theme?.theme);
return userThemeOption || null;
}, [userProfile?.theme?.theme]);
const handleThemeChange = useCallback(
async (themeOption: I_THEME_OPTION) => {
setTheme(themeOption.value);
// If switching to custom theme and user has saved custom colors, apply them immediately
if (
themeOption.value === "custom" &&
userProfile?.theme?.primary &&
userProfile?.theme?.background &&
userProfile?.theme?.darkPalette !== undefined
) {
applyCustomTheme(
userProfile.theme.primary,
userProfile.theme.background,
userProfile.theme.darkPalette ? "dark" : "light"
);
}
const updateCurrentUserThemePromise = updateUserTheme({ theme: themeOption.value });
setPromiseToast(updateCurrentUserThemePromise, {
loading: "Updating theme...",
success: {
title: "Theme updated",
message: () => "Reloading to apply changes...",
},
error: {
title: "Error!",
message: () => "Failed to update theme. Please try again.",
},
});
// Wait for the promise to resolve, then reload after showing toast
try {
await updateCurrentUserThemePromise;
window.location.reload();
} catch (error) {
// Error toast already shown by setPromiseToast
console.error("Error updating theme:", error);
}
},
[setTheme, updateUserTheme, userProfile]
);
return (
<>
<PageHead title="Profile - Appearance" />
{userProfile ? (
<ProfileSettingContentWrapper>
<ProfileSettingContentHeader title={t("appearance")} />
<div className="grid grid-cols-12 gap-4 py-6 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-16 font-semibold text-primary">{t("theme")}</h4>
<p className="text-13 text-secondary">{t("select_or_customize_your_interface_color_scheme")}</p>
</div>
<div className="col-span-12 sm:col-span-6">
<ThemeSwitch value={currentTheme} onChange={handleThemeChange} />
</div>
</div>
{userProfile?.theme?.theme === "custom" && <CustomThemeSelector />}
</ProfileSettingContentWrapper>
) : (
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
<LogoSpinner />
</div>
)}
</>
);
}
export default observer(ProfileAppearancePage);
@@ -1,37 +0,0 @@
import useSWR from "swr";
// components
import { useTranslation } from "@plane/i18n";
import { PageHead } from "@/components/core/page-title";
import { EmailNotificationForm } from "@/components/profile/notification/email-notification-form";
import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header";
import { ProfileSettingContentWrapper } from "@/components/profile/profile-setting-content-wrapper";
import { EmailSettingsLoader } from "@/components/ui/loader/settings/email";
// services
import { UserService } from "@/services/user.service";
const userService = new UserService();
export default function ProfileNotificationPage() {
const { t } = useTranslation();
// fetching user email notification settings
const { data, isLoading } = useSWR("CURRENT_USER_EMAIL_NOTIFICATION_SETTINGS", () =>
userService.currentUserEmailNotificationSettings()
);
if (!data || isLoading) {
return <EmailSettingsLoader />;
}
return (
<>
<PageHead title={`${t("profile.label")} - ${t("notifications")}`} />
<ProfileSettingContentWrapper>
<ProfileSettingContentHeader
title={t("email_notifications")}
description={t("stay_in_the_loop_on_issues_you_are_subscribed_to_enable_this_to_get_notified")}
/>
<EmailNotificationForm data={data} />
</ProfileSettingContentWrapper>
</>
);
}
-34
View File
@@ -1,34 +0,0 @@
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
import { PageHead } from "@/components/core/page-title";
import { ProfileForm } from "@/components/profile/form";
import { ProfileSettingContentWrapper } from "@/components/profile/profile-setting-content-wrapper";
// hooks
import { useUser } from "@/hooks/store/user";
function ProfileSettingsPage() {
const { t } = useTranslation();
// store hooks
const { data: currentUser, userProfile } = useUser();
if (!currentUser)
return (
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
<LogoSpinner />
</div>
);
return (
<>
<PageHead title={`${t("profile.label")} - ${t("general_settings")}`} />
<ProfileSettingContentWrapper>
<ProfileForm user={currentUser} profile={userProfile.data} />
</ProfileSettingContentWrapper>
</>
);
}
export default observer(ProfileSettingsPage);
-279
View File
@@ -1,279 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
// icons
import { LogOut, MoveLeft, Activity, Bell, CircleUser, KeyRound, Settings2, CirclePlus, Mails } from "lucide-react";
// plane imports
import { PROFILE_ACTION_LINKS } from "@plane/constants";
import { useOutsideClickDetector } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
import { ChevronLeftIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import { cn, getFileURL } from "@plane/utils";
// components
import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUser, useUserSettings } from "@/hooks/store/user";
import { usePlatformOS } from "@/hooks/use-platform-os";
const WORKSPACE_ACTION_LINKS = [
{
key: "create_workspace",
Icon: CirclePlus,
i18n_label: "create_workspace",
href: "/create-workspace",
},
{
key: "invitations",
Icon: Mails,
i18n_label: "workspace_invites",
href: "/invitations",
},
];
function ProjectActionIcons({ type, size, className = "" }: { type: string; size?: number; className?: string }) {
const icons = {
profile: CircleUser,
security: KeyRound,
activity: Activity,
preferences: Settings2,
notifications: Bell,
"api-tokens": KeyRound,
};
if (type === undefined) return null;
const Icon = icons[type as keyof typeof icons];
if (!Icon) return null;
return <Icon size={size} className={className} />;
}
export const ProfileLayoutSidebar = observer(function ProfileLayoutSidebar() {
// states
const [isSigningOut, setIsSigningOut] = useState(false);
// router
const pathname = usePathname();
// store hooks
const { sidebarCollapsed, toggleSidebar } = useAppTheme();
const { data: currentUser, signOut } = useUser();
const { data: currentUserSettings } = useUserSettings();
const { workspaces } = useWorkspace();
const { isMobile } = usePlatformOS();
const { t } = useTranslation();
const workspacesList = Object.values(workspaces ?? {});
// redirect url for normal mode
const redirectWorkspaceSlug =
currentUserSettings?.workspace?.last_workspace_slug ||
currentUserSettings?.workspace?.fallback_workspace_slug ||
"";
const ref = useRef<HTMLDivElement>(null);
useOutsideClickDetector(ref, () => {
if (sidebarCollapsed === false) {
if (window.innerWidth < 768) {
toggleSidebar();
}
}
});
useEffect(() => {
const handleResize = () => {
if (window.innerWidth <= 768) {
toggleSidebar(true);
}
};
handleResize();
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, [toggleSidebar]);
const handleItemClick = () => {
if (window.innerWidth < 768) {
toggleSidebar();
}
};
const handleSignOut = async () => {
setIsSigningOut(true);
await signOut()
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: t("sign_out.toast.error.title"),
message: t("sign_out.toast.error.message"),
})
)
.finally(() => setIsSigningOut(false));
};
return (
<div
className={`fixed inset-y-0 z-20 flex h-full flex-shrink-0 flex-grow-0 flex-col border-r border-subtle bg-surface-1 duration-300 md:relative
${sidebarCollapsed ? "-ml-[250px]" : ""}
sm:${sidebarCollapsed ? "-ml-[250px]" : ""}
md:ml-0 ${sidebarCollapsed ? "w-[70px]" : "w-[250px]"}
`}
>
<div ref={ref} className="flex h-full w-full flex-col gap-y-4">
<Link href={`/${redirectWorkspaceSlug}`} onClick={handleItemClick}>
<div
className={`flex flex-shrink-0 items-center gap-2 truncate px-4 pt-4 ${
sidebarCollapsed ? "justify-center" : ""
}`}
>
<span className="grid h-5 w-5 flex-shrink-0 place-items-center">
<ChevronLeftIcon className="h-5 w-5" strokeWidth={1} />
</span>
{!sidebarCollapsed && (
<h4 className="truncate text-16 font-semibold text-secondary">{t("profile_settings")}</h4>
)}
</div>
</Link>
<div className="flex flex-shrink-0 flex-col overflow-x-hidden">
{!sidebarCollapsed && (
<h6 className="rounded-sm px-6 text-13 font-semibold text-placeholder">{t("your_account")}</h6>
)}
<div className="vertical-scrollbar scrollbar-sm mt-2 px-4 h-full space-y-1 overflow-y-auto">
{PROFILE_ACTION_LINKS.map((link) => {
if (link.key === "change-password" && currentUser?.is_password_autoset) return null;
return (
<Link key={link.key} href={link.href} className="block w-full" onClick={handleItemClick}>
<Tooltip
tooltipContent={t(link.key)}
position="right"
className="ml-2"
disabled={!sidebarCollapsed}
isMobile={isMobile}
>
<SidebarNavItem
key={link.key}
className={`${sidebarCollapsed ? "p-0 size-8 aspect-square justify-center mx-auto" : ""}`}
isActive={link.highlight(pathname)}
>
<div className="flex items-center gap-1.5 py-[1px]">
<ProjectActionIcons type={link.key} size={16} />
{!sidebarCollapsed && <p className="text-13 leading-5 font-medium">{t(link.i18n_label)}</p>}
</div>
</SidebarNavItem>
</Tooltip>
</Link>
);
})}
</div>
</div>
<div className="flex flex-col overflow-x-hidden">
{!sidebarCollapsed && (
<h6 className="rounded-sm px-6 text-13 font-semibold text-placeholder">{t("workspaces")}</h6>
)}
{workspacesList && workspacesList.length > 0 && (
<div
className={cn("vertical-scrollbar scrollbar-xs mt-2 px-4 h-full space-y-1.5 overflow-y-auto", {
"scrollbar-sm": !sidebarCollapsed,
"ml-2.5 px-1": sidebarCollapsed,
})}
>
{workspacesList.map((workspace) => (
<Link
key={workspace.id}
href={`/${workspace.slug}`}
className={`flex flex-grow cursor-pointer select-none items-center truncate text-left text-13 font-medium ${
sidebarCollapsed ? "justify-center" : `justify-between`
}`}
onClick={handleItemClick}
>
<span
className={`flex w-full flex-grow items-center gap-x-2 truncate rounded-md px-3 py-1 hover:bg-layer-1 ${
sidebarCollapsed ? "justify-center" : ""
}`}
>
<span
className={`relative flex h-6 w-6 flex-shrink-0 items-center justify-center p-2 text-11 uppercase ${
!workspace?.logo_url && "rounded-sm bg-accent-primary text-on-color"
}`}
>
{workspace?.logo_url && workspace.logo_url !== "" ? (
<img
src={getFileURL(workspace.logo_url)}
className="absolute left-0 top-0 h-full w-full rounded-sm object-cover"
alt="Workspace Logo"
/>
) : (
(workspace?.name?.charAt(0) ?? "...")
)}
</span>
{!sidebarCollapsed && <p className="truncate text-13 text-secondary">{workspace.name}</p>}
</span>
</Link>
))}
</div>
)}
<div className="mt-1.5 px-4">
{WORKSPACE_ACTION_LINKS.map((link) => (
<Link className="block w-full" key={link.key} href={link.href} onClick={handleItemClick}>
<Tooltip
tooltipContent={t(link.key)}
position="right"
className="ml-2"
disabled={!sidebarCollapsed}
isMobile={isMobile}
>
<div
className={`group flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-13 font-medium text-secondary outline-none hover:bg-layer-1 focus:bg-layer-1 ${
sidebarCollapsed ? "justify-center" : ""
}`}
>
{<link.Icon className="flex-shrink-0 size-4" />}
{!sidebarCollapsed && t(link.i18n_label)}
</div>
</Tooltip>
</Link>
))}
</div>
</div>
<div className="flex flex-shrink-0 flex-grow items-end px-6 py-2">
<div
className={`flex w-full ${
sidebarCollapsed ? "flex-col justify-center gap-2" : "items-center justify-between gap-2"
}`}
>
<button
type="button"
onClick={handleSignOut}
className="flex items-center justify-center gap-2 text-13 font-medium text-danger-primary"
disabled={isSigningOut}
>
<LogOut className="h-3.5 w-3.5" />
{!sidebarCollapsed && <span>{isSigningOut ? `${t("signing_out")}...` : t("sign_out")}</span>}
</button>
<button
type="button"
className="grid place-items-center rounded-md p-1.5 text-secondary outline-none hover:bg-surface-2 hover:text-primary md:hidden"
onClick={() => toggleSidebar()}
>
<MoveLeft className="h-3.5 w-3.5" />
</button>
<button
type="button"
className={`ml-auto hidden place-items-center rounded-md p-1.5 text-secondary outline-none hover:bg-surface-2 hover:text-primary md:grid ${
sidebarCollapsed ? "w-full" : ""
}`}
onClick={() => toggleSidebar()}
>
<MoveLeft className={`h-3.5 w-3.5 duration-300 ${sidebarCollapsed ? "rotate-180" : ""}`} />
</button>
</div>
</div>
</div>
</div>
);
});
@@ -0,0 +1,55 @@
import { observer } from "mobx-react";
// plane imports
import { PROFILE_SETTINGS_TABS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { TProfileSettingsTabs } from "@plane/types";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
import { PageHead } from "@/components/core/page-title";
import { ProfileSettingsContent } from "@/components/settings/profile/content";
import { ProfileSettingsSidebarRoot } from "@/components/settings/profile/sidebar";
// hooks
import { useUser } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
// local imports
import type { Route } from "../+types/layout";
function ProfileSettingsPage(props: Route.ComponentProps) {
const { profileTabId } = props.params;
// router
const router = useAppRouter();
// store hooks
const { data: currentUser } = useUser();
// translation
const { t } = useTranslation();
// derived values
const isAValidTab = PROFILE_SETTINGS_TABS.includes(profileTabId as TProfileSettingsTabs);
if (!currentUser || !isAValidTab)
return (
<div className="size-full grid place-items-center px-4">
<LogoSpinner />
</div>
);
return (
<>
<PageHead title={`${t("profile.label")} - ${t("general_settings")}`} />
<div className="relative size-full">
<div className="size-full flex">
<ProfileSettingsSidebarRoot
activeTab={profileTabId as TProfileSettingsTabs}
className="w-[250px]"
updateActiveTab={(tab) => router.push(`/settings/profile/${tab}`)}
/>
<ProfileSettingsContent
activeTab={profileTabId as TProfileSettingsTabs}
className="grow py-20 px-page-x mx-auto w-fit max-w-225"
/>
</div>
</div>
</>
);
}
export default observer(ProfileSettingsPage);
@@ -1,20 +1,17 @@
// components
import { Outlet } from "react-router";
// wrappers
// components
import { ProjectsAppPowerKProvider } from "@/components/power-k/projects-app-provider";
// lib
import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper";
// layout
import { ProfileLayoutSidebar } from "./sidebar";
export default function ProfileSettingsLayout() {
return (
<>
<ProjectsAppPowerKProvider />
<AuthenticationWrapper>
<div className="relative flex h-full w-full overflow-hidden rounded-lg border border-subtle">
<ProfileLayoutSidebar />
<main className="relative flex h-full w-full flex-col overflow-hidden bg-surface-1">
<div className="h-full w-full overflow-hidden">
<div className="relative flex size-full overflow-hidden bg-canvas p-2">
<main className="relative flex flex-col size-full overflow-hidden bg-surface-1 rounded-lg border border-subtle">
<div className="size-full overflow-hidden">
<Outlet />
</div>
</main>
+27 -37
View File
@@ -278,34 +278,6 @@ export const coreRoutes: RouteConfigEntry[] = [
),
]),
// --------------------------------------------------------------------
// ACCOUNT SETTINGS
// --------------------------------------------------------------------
layout("./(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx", [
route(":workspaceSlug/settings/account", "./(all)/[workspaceSlug]/(settings)/settings/account/page.tsx"),
route(
":workspaceSlug/settings/account/activity",
"./(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx"
),
route(
":workspaceSlug/settings/account/preferences",
"./(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx"
),
route(
":workspaceSlug/settings/account/notifications",
"./(all)/[workspaceSlug]/(settings)/settings/account/notifications/page.tsx"
),
route(
":workspaceSlug/settings/account/security",
"./(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx"
),
route(
":workspaceSlug/settings/account/api-tokens",
"./(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx"
),
]),
// --------------------------------------------------------------------
// PROJECT SETTINGS
// --------------------------------------------------------------------
@@ -326,8 +298,24 @@ export const coreRoutes: RouteConfigEntry[] = [
),
// Project Features
route(
":workspaceSlug/settings/projects/:projectId/features",
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx"
":workspaceSlug/settings/projects/:projectId/features/cycles",
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/cycles/page.tsx"
),
route(
":workspaceSlug/settings/projects/:projectId/features/modules",
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/modules/page.tsx"
),
route(
":workspaceSlug/settings/projects/:projectId/features/views",
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/views/page.tsx"
),
route(
":workspaceSlug/settings/projects/:projectId/features/pages",
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/pages/page.tsx"
),
route(
":workspaceSlug/settings/projects/:projectId/features/intake",
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/intake/page.tsx"
),
// Project States
route(
@@ -363,12 +351,8 @@ export const coreRoutes: RouteConfigEntry[] = [
// PROFILE SETTINGS
// --------------------------------------------------------------------
layout("./(all)/profile/layout.tsx", [
route("profile", "./(all)/profile/page.tsx"),
route("profile/activity", "./(all)/profile/activity/page.tsx"),
route("profile/appearance", "./(all)/profile/appearance/page.tsx"),
route("profile/notifications", "./(all)/profile/notifications/page.tsx"),
route("profile/security", "./(all)/profile/security/page.tsx"),
layout("./(all)/settings/profile/layout.tsx", [
route("settings/profile/:profileTabId", "./(all)/settings/profile/[profileTabId]/page.tsx"),
]),
]),
@@ -389,7 +373,7 @@ export const coreRoutes: RouteConfigEntry[] = [
route(":workspaceSlug/analytics", "routes/redirects/core/analytics.tsx"),
// API tokens redirect: /:workspaceSlug/settings/api-tokens
// → /:workspaceSlug/settings/account/api-tokens
// → /settings/profile/api-tokens
route(":workspaceSlug/settings/api-tokens", "routes/redirects/core/api-tokens.tsx"),
// Inbox redirect: /:workspaceSlug/projects/:projectId/inbox
@@ -406,4 +390,10 @@ export const coreRoutes: RouteConfigEntry[] = [
// Register redirect
route("register", "routes/redirects/core/register.tsx"),
// Profile settings redirects
route("profile/*", "routes/redirects/core/profile-settings.tsx"),
// Account settings redirects
route(":workspaceSlug/settings/account/*", "routes/redirects/core/workspace-account-settings.tsx"),
] satisfies RouteConfig;
@@ -1,9 +1,7 @@
import { redirect } from "react-router";
import type { Route } from "./+types/api-tokens";
export const clientLoader = ({ params }: Route.ClientLoaderArgs) => {
const { workspaceSlug } = params;
throw redirect(`/${workspaceSlug}/settings/account/api-tokens/`);
export const clientLoader = () => {
throw redirect(`/settings/profile/api-tokens/`);
};
export default function ApiTokens() {
+1 -1
View File
@@ -14,7 +14,7 @@ export const coreRedirectRoutes: RouteConfigEntry[] = [
route(":workspaceSlug/analytics", "routes/redirects/core/analytics.tsx"),
// API tokens redirect: /:workspaceSlug/settings/api-tokens
// → /:workspaceSlug/settings/account/api-tokens
// → /settings/profile/api-tokens
route(":workspaceSlug/settings/api-tokens", "routes/redirects/core/api-tokens.tsx"),
// Inbox redirect: /:workspaceSlug/projects/:projectId/inbox
@@ -0,0 +1,12 @@
import { redirect } from "react-router";
import type { Route } from "./+types/profile-settings";
export const clientLoader = ({ params, request }: Route.ClientLoaderArgs) => {
const searchParams = new URL(request.url).searchParams;
const splat = params["*"] || "";
throw redirect(`/settings/profile/${splat || "general"}?${searchParams.toString()}`);
};
export default function ProfileSettings() {
return null;
}
@@ -0,0 +1,12 @@
import { redirect } from "react-router";
import type { Route } from "./+types/workspace-account-settings";
export const clientLoader = ({ params, request }: Route.ClientLoaderArgs) => {
const searchParams = new URL(request.url).searchParams;
const splat = params["*"] || "";
throw redirect(`/settings/profile/${splat || "general"}?${searchParams.toString()}`);
};
export default function WorkspaceAccountSettings() {
return null;
}
@@ -0,0 +1,26 @@
import { lazy, Suspense } from "react";
import { observer } from "mobx-react";
const ProfileSettingsModal = lazy(() =>
import("@/components/settings/profile/modal").then((module) => ({
default: module.ProfileSettingsModal,
}))
);
type TGlobalModalsProps = {
workspaceSlug: string;
};
/**
* GlobalModals component manages all workspace-level modals across Plane applications.
*
* This includes:
* - Profile settings modal
*/
export const GlobalModals = observer(function GlobalModals(_props: TGlobalModalsProps) {
return (
<Suspense fallback={null}>
<ProfileSettingsModal />
</Suspense>
);
});
@@ -74,7 +74,7 @@ export const TopNavigationRoot = observer(function TopNavigationRoot() {
<HelpMenuRoot />
<StarUsOnGitHubLink />
<div className="flex items-center justify-center size-8 hover:bg-layer-1-hover rounded-md">
<UserMenuRoot size="xs" />
<UserMenuRoot />
</div>
</div>
</div>
@@ -1,7 +0,0 @@
import { StartOfWeekPreference } from "@/components/profile/start-of-week-preference";
import { ThemeSwitcher } from "./theme-switcher";
export const PREFERENCE_COMPONENTS = {
theme: ThemeSwitcher,
start_of_week: StartOfWeekPreference,
};
@@ -10,8 +10,7 @@ import { applyCustomTheme } from "@plane/utils";
// components
import { CustomThemeSelector } from "@/components/core/theme/custom-theme-selector";
import { ThemeSwitch } from "@/components/core/theme/theme-switch";
// helpers
import { PreferencesSection } from "@/components/preferences/section";
import { SettingsControlItem } from "@/components/settings/control-item";
// hooks
import { useUserProfile } from "@/hooks/store/user";
@@ -79,18 +78,16 @@ export const ThemeSwitcher = observer(function ThemeSwitcher(props: {
return (
<>
<PreferencesSection
<SettingsControlItem
title={t(props.option.title)}
description={t(props.option.description)}
control={
<div>
<ThemeSwitch
value={currentTheme}
onChange={(themeOption) => {
void handleThemeChange(themeOption);
}}
/>
</div>
<ThemeSwitch
value={currentTheme}
onChange={(themeOption) => {
void handleThemeChange(themeOption);
}}
/>
}
/>
{userProfile.theme?.theme === "custom" && <CustomThemeSelector />}
@@ -6,6 +6,7 @@ import { useTranslation } from "@plane/i18n";
import type { TBillingFrequency, TProductBillingFrequency } from "@plane/types";
import { EProductSubscriptionEnum } from "@plane/types";
// components
import { SettingsBoxedControlItem } from "@/components/settings/boxed-control-item";
import { SettingsHeading } from "@/components/settings/heading";
// local imports
import { PlansComparison } from "./comparison/root";
@@ -37,32 +38,28 @@ export const BillingRoot = observer(function BillingRoot() {
setProductBillingFrequency({ ...productBillingFrequency, [subscriptionType]: frequency });
return (
<section className="relative size-full flex flex-col overflow-y-auto scrollbar-hide">
<SettingsHeading
title={t("workspace_settings.settings.billing_and_plans.heading")}
description={t("workspace_settings.settings.billing_and_plans.description")}
/>
<section className="relative size-full overflow-y-auto scrollbar-hide">
<div>
<div className="py-6">
<div className="px-6 py-4 rounded-lg bg-layer-1">
<div className="flex gap-2 items-center justify-between">
<div className="flex flex-col gap-1">
<h4 className="text-h4-bold text-primary">Community</h4>
<div className="text-caption-md-medium text-secondary">
Unlimited projects, issues, cycles, modules, pages, and storage
</div>
</div>
</div>
</div>
<SettingsHeading
title={t("workspace_settings.settings.billing_and_plans.heading")}
description={t("workspace_settings.settings.billing_and_plans.description")}
/>
<div className="mt-6">
<SettingsBoxedControlItem
title="Community"
description="Unlimited projects, issues, cycles, modules, pages, and storage"
/>
</div>
<div className="text-h4-semibold mt-3">All plans</div>
</div>
<PlansComparison
isCompareAllFeaturesSectionOpen={isCompareAllFeaturesSectionOpen}
getBillingFrequency={getBillingFrequency}
setBillingFrequency={setBillingFrequency}
setIsCompareAllFeaturesSectionOpen={setIsCompareAllFeaturesSectionOpen}
/>
<div className="mt-10 flex flex-col gap-y-3">
<h4 className="text-h6-semibold">All plans</h4>
<PlansComparison
isCompareAllFeaturesSectionOpen={isCompareAllFeaturesSectionOpen}
getBillingFrequency={getBillingFrequency}
setBillingFrequency={setBillingFrequency}
setIsCompareAllFeaturesSectionOpen={setIsCompareAllFeaturesSectionOpen}
/>
</div>
</section>
);
});
@@ -1,15 +1,14 @@
import { useState } from "react";
import { observer } from "mobx-react";
// types
// plane imports
import { WORKSPACE_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { ChevronDownIcon, ChevronUpIcon } from "@plane/propel/icons";
import type { IWorkspace } from "@plane/types";
// ui
import { Collapsible } from "@plane/ui";
import { DeleteWorkspaceModal } from "./delete-workspace-modal";
// components
import { SettingsBoxedControlItem } from "@/components/settings/boxed-control-item";
// local imports
import { DeleteWorkspaceModal } from "./delete-workspace-modal";
type TDeleteWorkspace = {
workspace: IWorkspace | null;
@@ -18,8 +17,8 @@ type TDeleteWorkspace = {
export const DeleteWorkspaceSection = observer(function DeleteWorkspaceSection(props: TDeleteWorkspace) {
const { workspace } = props;
// states
const [isOpen, setIsOpen] = useState(false);
const [deleteWorkspaceModal, setDeleteWorkspaceModal] = useState(false);
// translation
const { t } = useTranslation();
return (
@@ -29,40 +28,19 @@ export const DeleteWorkspaceSection = observer(function DeleteWorkspaceSection(p
isOpen={deleteWorkspaceModal}
onClose={() => setDeleteWorkspaceModal(false)}
/>
<div className="border-t border-subtle">
<div className="w-full">
<Collapsible
isOpen={isOpen}
onToggle={() => setIsOpen(!isOpen)}
className="w-full"
buttonClassName="flex w-full items-center justify-between py-4"
title={
<>
<span className="text-body-md-medium tracking-tight">
{t("workspace_settings.settings.general.delete_workspace")}
</span>
{isOpen ? <ChevronUpIcon className="h-5 w-5" /> : <ChevronDownIcon className="h-5 w-5" />}
</>
}
<SettingsBoxedControlItem
title={t("workspace_settings.settings.general.delete_workspace")}
description={t("workspace_settings.settings.general.delete_workspace_description")}
control={
<Button
variant="error-outline"
onClick={() => setDeleteWorkspaceModal(true)}
data-ph-element={WORKSPACE_TRACKER_ELEMENTS.DELETE_WORKSPACE_BUTTON}
>
<div className="flex flex-col gap-4">
<span className="text-body-sm-regular tracking-tight">
{t("workspace_settings.settings.general.delete_workspace_description")}
</span>
<div>
<Button
variant="error-fill"
size="lg"
onClick={() => setDeleteWorkspaceModal(true)}
data-ph-element={WORKSPACE_TRACKER_ELEMENTS.DELETE_WORKSPACE_BUTTON}
>
{t("workspace_settings.settings.general.delete_btn")}
</Button>
</div>
</div>
</Collapsible>
</div>
</div>
{t("delete")}
</Button>
}
/>
</>
);
});
@@ -1,2 +1 @@
export * from "./features";
export * from "./tabs";
@@ -1,82 +0,0 @@
// icons
import { EUserPermissions } from "@plane/constants";
import { SettingIcon } from "@/components/icons/attachment";
// types
import type { Props } from "@/components/icons/types";
// constants
export const PROJECT_SETTINGS = {
general: {
key: "general",
i18n_label: "common.general",
href: ``,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/`,
Icon: SettingIcon,
},
members: {
key: "members",
i18n_label: "common.members",
href: `/members`,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/members/`,
Icon: SettingIcon,
},
features: {
key: "features",
i18n_label: "common.features",
href: `/features`,
access: [EUserPermissions.ADMIN],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/features/`,
Icon: SettingIcon,
},
states: {
key: "states",
i18n_label: "common.states",
href: `/states`,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/states/`,
Icon: SettingIcon,
},
labels: {
key: "labels",
i18n_label: "common.labels",
href: `/labels`,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/labels/`,
Icon: SettingIcon,
},
estimates: {
key: "estimates",
i18n_label: "common.estimates",
href: `/estimates`,
access: [EUserPermissions.ADMIN],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/estimates/`,
Icon: SettingIcon,
},
automations: {
key: "automations",
i18n_label: "project_settings.automations.label",
href: `/automations`,
access: [EUserPermissions.ADMIN],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/automations/`,
Icon: SettingIcon,
},
};
export const PROJECT_SETTINGS_LINKS: {
key: string;
i18n_label: string;
href: string;
access: EUserPermissions[];
highlight: (pathname: string, baseUrl: string) => boolean;
Icon: React.FC<Props>;
}[] = [
PROJECT_SETTINGS["general"],
PROJECT_SETTINGS["members"],
PROJECT_SETTINGS["features"],
PROJECT_SETTINGS["states"],
PROJECT_SETTINGS["labels"],
PROJECT_SETTINGS["estimates"],
PROJECT_SETTINGS["automations"],
];
@@ -0,0 +1 @@
export * from "./theme-switcher";
@@ -0,0 +1,70 @@
import { useCallback, useMemo } from "react";
import { observer } from "mobx-react";
import { useTheme } from "next-themes";
// plane imports
import type { I_THEME_OPTION } from "@plane/constants";
import { THEME_OPTIONS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { setPromiseToast } from "@plane/propel/toast";
// components
import { CustomThemeSelector } from "@/components/core/theme/custom-theme-selector";
import { ThemeSwitch } from "@/components/core/theme/theme-switch";
import { SettingsControlItem } from "@/components/settings/control-item";
// hooks
import { useUserProfile } from "@/hooks/store/user";
export const ThemeSwitcher = observer(function ThemeSwitcher(props: {
option: {
id: string;
title: string;
description: string;
};
}) {
// store hooks
const { data: userProfile, updateUserTheme } = useUserProfile();
// theme
const { setTheme } = useTheme();
// translation
const { t } = useTranslation();
// derived values
const currentTheme = useMemo(() => {
const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile?.theme?.theme);
return userThemeOption || null;
}, [userProfile?.theme?.theme]);
const handleThemeChange = useCallback(
(themeOption: I_THEME_OPTION) => {
try {
setTheme(themeOption.value);
const updatePromise = updateUserTheme({ theme: themeOption.value });
setPromiseToast(updatePromise, {
loading: "Updating theme...",
success: {
title: "Success!",
message: () => "Theme updated successfully!",
},
error: {
title: "Error!",
message: () => "Failed to update the theme",
},
});
} catch (error) {
console.error("Error updating theme:", error);
}
},
[updateUserTheme]
);
if (!userProfile) return null;
return (
<>
<SettingsControlItem
title={t(props.option.title)}
description={t(props.option.description)}
control={<ThemeSwitch value={currentTheme} onChange={handleThemeChange} />}
/>
{userProfile.theme?.theme === "custom" && <CustomThemeSelector />}
</>
);
});
@@ -2,14 +2,14 @@ import { useMemo, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { ArchiveRestore } from "lucide-react";
// types
// plane imports
import { PROJECT_AUTOMATION_MONTHS, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { IProject } from "@plane/types";
// ui
import { CustomSelect, Loader, ToggleSwitch } from "@plane/ui";
// component
import { SelectMonthModal } from "@/components/automation";
import { SettingsControlItem } from "@/components/settings/control-item";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
@@ -61,25 +61,22 @@ export const AutoArchiveAutomation = observer(function AutoArchiveAutomation(pro
handleClose={() => setmonthModal(false)}
handleChange={handleChange}
/>
<div className="flex flex-col gap-4 border-b border-subtle py-6">
<div className="flex items-center justify-between">
<div className="flex items-start gap-3">
<div className="flex items-center justify-center rounded-sm bg-layer-3 p-3">
<ArchiveRestore className="h-4 w-4 flex-shrink-0 text-primary" />
</div>
<div className="">
<h4 className="text-13 font-medium">{t("project_settings.automations.auto-archive.title")}</h4>
<p className="text-13 tracking-tight text-tertiary">
{t("project_settings.automations.auto-archive.description")}
</p>
</div>
<div className="flex flex-col gap-4 border-b border-subtle py-2">
<div className="flex items-center gap-3">
<div className="shrink-0 size-10 grid place-items-center rounded-sm bg-layer-2">
<ArchiveRestore className="shrink-0 size-4 text-primary" />
</div>
<ToggleSwitch value={autoArchiveStatus} onChange={handleToggleArchive} size="sm" disabled={!isAdmin} />
<SettingsControlItem
title={t("project_settings.automations.auto-archive.title")}
description={t("project_settings.automations.auto-archive.description")}
control={
<ToggleSwitch value={autoArchiveStatus} onChange={handleToggleArchive} size="sm" disabled={!isAdmin} />
}
/>
</div>
{currentProjectDetails ? (
autoArchiveStatus && (
<div className="mx-6">
<div className="ml-13">
<div className="flex w-full items-center justify-between gap-2 rounded-sm border border-subtle bg-surface-2 px-5 py-4">
<div className="w-1/2 text-13 font-medium">
{t("project_settings.automations.auto-archive.duration")}
@@ -90,9 +87,7 @@ export const AutoArchiveAutomation = observer(function AutoArchiveAutomation(pro
label={`${currentProjectDetails?.archive_in} ${
currentProjectDetails?.archive_in === 1 ? "month" : "months"
}`}
onChange={(val: number) => {
handleChange({ archive_in: val });
}}
onChange={(val: number) => void handleChange({ archive_in: val })}
input
disabled={!isAdmin}
>
@@ -117,7 +112,7 @@ export const AutoArchiveAutomation = observer(function AutoArchiveAutomation(pro
</div>
)
) : (
<Loader className="mx-6">
<Loader className="ml-13">
<Loader.Item height="50px" />
</Loader>
)}

Some files were not shown because too many files have changed in this diff Show More