release: v1.3.1 #8917

This commit is contained in:
sriram veeraghanta
2026-05-15 01:39:46 +05:30
committed by GitHub
93 changed files with 1175 additions and 841 deletions
+58
View File
@@ -0,0 +1,58 @@
---
name: pr-description
description: Generate a PR description following the project's GitHub PR template. Analyzes the current branch's changes against the base branch to produce a complete, filled-out PR description.
user_invocable: true
---
# PR Description Generator
Generate a pull request description based on the project's PR template at `.github/pull_request_template.md`.
## Steps
1. **Determine the base branch**: Prefer the PR's actual `baseRefName` (via `gh pr view <PR> --json baseRefName`) when a PR exists. Otherwise default by intent — feature PRs target `preview`, release PRs target `master`. If still ambiguous, ask the user.
2. **Analyze changes**: Run the following to understand what changed:
- `git log <base>...HEAD --oneline` to see all commits on this branch
- `git diff <base>...HEAD --stat` to see which files changed
- `git diff <base>...HEAD` to read the actual diff (use `--no-color`)
- If the diff is very large, focus on the most important files first
3. **Fill out the PR template** with the following sections:
### Description
Write a clear, concise summary of what the PR does and why. Focus on the "what" and "why", not line-by-line changes. Mention any important implementation decisions.
### Type of Change
Check the appropriate box(es) based on the changes:
- Bug fix (non-breaking change which fixes an issue)
- Feature (non-breaking change which adds functionality)
- Improvement (non-breaking change that improves existing functionality)
- Code refactoring
- Performance improvements
- Documentation update
### Screenshots and Media
Leave this section for the user to fill in, preserving the existing placeholder comment from `.github/pull_request_template.md` verbatim rather than introducing different text.
### Test Scenarios
Based on the code changes, suggest specific test scenarios that should be verified. Be concrete (e.g., "Navigate to project settings and verify the new toggle works") rather than generic.
### References
- If commit messages or branch name reference a work item identifier (e.g., `WEB-1234`), include it
- If the user provides a linked issue, include it
- If Sentry issue links or IDs (e.g., `SENTRY-ABC123`, Sentry URLs) were mentioned earlier in the conversation, include them as references
4. **Output format**: Print the filled-out markdown template so the user can copy it directly. Do NOT wrap it in a code fence — output the raw markdown.
## Guidelines
- Keep the description concise but informative
- Use bullet points for multiple changes
- Focus on user-facing impact, not implementation details
- If the branch has a Plane work item ID in its name (e.g., `WEB-1234`), reference it
- Don't fabricate test scenarios that aren't relevant to the actual changes
+147
View File
@@ -0,0 +1,147 @@
---
name: release-notes
description: "Generate release notes for a Plane release PR in `makeplane/plane` (semver, e.g. `release: vX.Y.Z`). Reads PR commits, filters out noise, categorizes by conventional-commit type, optionally enriches via Plane MCP, and writes the result as the PR description."
user_invocable: true
---
# Release Notes Generator
Generate structured release notes from a Plane release PR by parsing its commit list, then update the PR description.
## Versioning
Plane community uses **semver** (`vX.Y.Z`, major.minor.patch) for releases.
- PR title format: `release: vX.Y.Z`
- Source branch: `canary`
- Target branch: `master`
## When to Use
- User links/mentions a Plane release PR (e.g. `release: v1.3.0`) and asks for release notes
- User asks to "create release notes" / "update PR description" for a release PR in `makeplane/plane`
- The branch is named `canary` or `release/x.y.z` and the base is `master`
## Steps
### 1. Fetch commits
```bash
gh pr view <PR_NUM> --json title,body,baseRefName,headRefName,commits \
--jq '.commits[] | .messageHeadline + "\n---BODY---\n" + .messageBody + "\n===END==="'
```
For a quick scan first:
```bash
gh pr view <PR_NUM> --json commits \
--jq '.commits[] | {oid: .oid[0:10], message: .messageHeadline}'
```
### 2. Filter out noise
**Always exclude** these commits — mechanical, not user-facing:
| Pattern | Reason |
| -------------------------------------------- | -------------- |
| `fix: merge conflicts` | Merge artifact |
| `Merge branch '...' of github.com:...` | Merge artifact |
| `Revert "..."` (when immediately re-applied) | Internal churn |
### 3. Parse work item IDs
Most meaningful commits begin with a Plane work item identifier in brackets:
- `[WEB-XXXX]` — web/frontend product items
- `[SILO-XXXX]` — Silo (integrations: Slack, GitHub, GitLab, Jira/Linear)
- `[MOBILE-XXXX]`, `[API-XXXX]`, etc.
Always preserve these IDs in the release notes — they let readers click through to the source ticket.
### 4. (Optional) Enrich via Plane MCP
For larger features where the commit headline is terse, fetch the work item:
```text
mcp__plane__retrieve_work_item_by_identifier(project_identifier="WEB", issue_identifier=6874)
```
Use the returned `name` and `description_stripped` to flesh out the bullet. Skip this for routine fixes — commit body is usually enough. Don't enrich every item (slow + work item descriptions are often empty).
### 5. Categorize by conventional-commit type
| Commit prefix | Section |
| -------------------------------- | ------------------- |
| `feat:`, `feat(scope):` | ✨ New Features |
| `fix:`, `fix(scope):` | 🐛 Bug Fixes |
| `refactor:` | 🔧 Refactor & Chore |
| `chore:`, `chore(scope):` | 🔧 Refactor & Chore |
| `chore(deps):`, dependabot bumps | 📦 Dependencies |
### 6. Format
```markdown
# Release vX.Y.Z
## ✨ New Features
- **<Short title>** — [WEB-XXXX] (#PR_NUM)
Optional 12 sentence elaboration drawn from commit body.
## 🐛 Bug Fixes
- **<Short title>** — [WEB-XXXX] (#PR_NUM)
## 🔧 Refactor & Chore
- **<Short title>** — [WEB-XXXX] (#PR_NUM)
## 📦 Dependencies
- Bump `<package>` X.Y.Z → A.B.C (#PR_NUM)
```
Rules:
- Lead with a bold human-readable title (rewrite the commit subject if cryptic)
- Always include the work item ID in brackets and the merge PR number in parens
- Add a sub-line elaboration only when the commit body has substance worth surfacing (acceptance criteria, scope notes, gotchas like "behind feature flag", "requires migration", "requires Vercel setting")
- Drop empty sections
### 7. Update the PR description
```bash
gh pr edit <PR_NUM> --body "$(cat <<'EOF'
<release notes markdown>
EOF
)"
```
Always use a HEREDOC with single-quoted `'EOF'` so backticks/dollars in the notes are preserved.
## Quick Reference: end-to-end
```bash
PR=2498
gh pr view $PR --json commits --jq '.commits[] | .messageHeadline + "\n---\n" + .messageBody + "\n==="' > /tmp/commits.txt
# read /tmp/commits.txt, filter, categorize, draft notes
gh pr edit $PR --body "$(cat <<'EOF'
... release notes ...
EOF
)"
```
## Common Mistakes
- **Including `fix: merge conflicts`** — merge artifact, no functional content
- **Dropping the work item ID** — readers rely on `[WEB-XXXX]` to navigate to the ticket
- **Over-enriching with MCP lookups** — work item descriptions are often empty; commit body is usually richer
- **Missing the merge PR number** — always include `(#NNNN)` from the commit subject so reviewers can audit the source PR
- **Using `--body` without HEREDOC** — backticks/dollar signs get shell-interpreted and corrupt the notes
- **Editing the title** — release PR titles are version markers; only edit the body
## Plane-Specific Conventions
- Release PRs go from `canary``master`
- PR title format: `release: vX.Y.Z` semver (major.minor.patch)
- Commits coming from feature branches always carry a work item ID; commits without one are usually infra/chores
+3
View File
@@ -16,6 +16,9 @@ jobs:
contents: read
security-events: write
env:
CODEQL_ACTION_FILE_COVERAGE_ON_PRS: "false"
strategy:
fail-fast: false
matrix:
+8 -1
View File
@@ -1 +1,8 @@
eslint.config.mjs @lifeiscontent
.oxlintrc.json @sriramveeraghanta @lifeiscontent
.oxfmtrc.json @sriramveeraghanta @lifeiscontent
apps/api/ @dheeru0198 @pablohashescobar
apps/web/ @sriramveeraghanta
apps/space/ @sriramveeraghanta
apps/admin/ @sriramveeraghanta
apps/live/ @Palanikannan1437
deployments/ @mguptahub
+1 -1
View File
@@ -10,7 +10,7 @@
<p align="center">
<a href="https://plane.so/"><b>Website</b></a> •
<a href="https://forum.plane.so"><b>Forum</b></a> •
<a href="https://twitter.com/planepowers"><b>Twitter</b></a> •
<a href="https://x.com/planepowers"><b>X</b></a> •
<a href="https://docs.plane.so/"><b>Documentation</b></a>
</p>
+1 -1
View File
@@ -4,7 +4,7 @@ WORKDIR /app
ENV TURBO_TELEMETRY_DISABLED=1
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH"
ENV CI=1
RUN corepack enable pnpm
@@ -16,8 +16,6 @@ import { Input, ToggleSwitch } from "@plane/ui";
import { ControllerInput } from "@/components/common/controller-input";
// hooks
import { useInstance } from "@/hooks/store";
// components
import { IntercomConfig } from "./intercom";
export interface IGeneralConfigurationForm {
instance: IInstance;
@@ -27,14 +25,13 @@ export interface IGeneralConfigurationForm {
export const GeneralConfigurationForm = observer(function GeneralConfigurationForm(props: IGeneralConfigurationForm) {
const { instance, instanceAdmins } = props;
// hooks
const { instanceConfigurations, updateInstanceInfo, updateInstanceConfigurations } = useInstance();
const { updateInstanceInfo } = useInstance();
// form data
const {
handleSubmit,
control,
formState: { errors, isSubmitting },
watch,
} = useForm<Partial<IInstance>>({
defaultValues: {
instance_name: instance?.instance_name,
@@ -45,17 +42,6 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
const onSubmit = async (formData: Partial<IInstance>) => {
const payload: Partial<IInstance> = { ...formData };
// update the intercom configuration
const isIntercomEnabled =
instanceConfigurations?.find((config) => config.key === "IS_INTERCOM_ENABLED")?.value === "1";
if (!payload.is_telemetry_enabled && isIntercomEnabled) {
try {
await updateInstanceConfigurations({ IS_INTERCOM_ENABLED: "0" });
} catch (error) {
console.error(error);
}
}
await updateInstanceInfo(payload)
.then(() =>
setToast({
@@ -112,8 +98,7 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
</div>
<div className="space-y-6">
<div className="border-b border-subtle pb-1.5 text-16 font-medium text-primary">Chat + telemetry</div>
<IntercomConfig isTelemetryEnabled={watch("is_telemetry_enabled") ?? false} />
<div className="border-b border-subtle pb-1.5 text-16 font-medium text-primary">Telemetry</div>
<div className="flex items-center gap-14">
<div className="flex grow items-center gap-4">
<div className="shrink-0">
@@ -1,86 +0,0 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
import { MessageSquare } from "lucide-react";
import type { IFormattedInstanceConfiguration } from "@plane/types";
import { ToggleSwitch } from "@plane/ui";
// hooks
import { useInstance } from "@/hooks/store";
type TIntercomConfig = {
isTelemetryEnabled: boolean;
};
export const IntercomConfig = observer(function IntercomConfig(props: TIntercomConfig) {
const { isTelemetryEnabled } = props;
// hooks
const { instanceConfigurations, updateInstanceConfigurations, fetchInstanceConfigurations } = useInstance();
// states
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// derived values
const isIntercomEnabled = isTelemetryEnabled
? instanceConfigurations
? instanceConfigurations?.find((config) => config.key === "IS_INTERCOM_ENABLED")?.value === "1"
? true
: false
: undefined
: false;
const { isLoading } = useSWR(isTelemetryEnabled ? "INSTANCE_CONFIGURATIONS" : null, () =>
isTelemetryEnabled ? fetchInstanceConfigurations() : null
);
const initialLoader = isLoading && isIntercomEnabled === undefined;
const submitInstanceConfigurations = async (payload: Partial<IFormattedInstanceConfiguration>) => {
try {
await updateInstanceConfigurations(payload);
} catch (error) {
console.error(error);
} finally {
setIsSubmitting(false);
}
};
const enableIntercomConfig = () => {
void submitInstanceConfigurations({ IS_INTERCOM_ENABLED: isIntercomEnabled ? "0" : "1" });
};
return (
<>
<div className="flex items-center gap-14">
<div className="flex grow items-center gap-4">
<div className="shrink-0">
<div className="flex size-11 items-center justify-center rounded-lg bg-layer-1">
<MessageSquare className="size-5 p-0.5 text-tertiary" />
</div>
</div>
<div className="grow">
<div className="text-13 leading-5 font-medium text-primary">Chat with us</div>
<div className="text-11 leading-5 font-regular text-tertiary">
Let your users chat with us via Intercom or another service. Toggling Telemetry off turns this off
automatically.
</div>
</div>
<div className="ml-auto">
<ToggleSwitch
value={isIntercomEnabled ? true : false}
onChange={enableIntercomConfig}
size="sm"
disabled={!isTelemetryEnabled || isSubmitting || initialLoader}
/>
</div>
</div>
</div>
</>
);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "admin",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"description": "Admin UI for Plane",
"license": "AGPL-3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "plane-api",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"description": "API server powering Plane's backend",
"license": "AGPL-3.0"
+25 -17
View File
@@ -480,44 +480,52 @@ class IssueLinkSerializer(BaseSerializer):
]
class IssueRelationRefSerializer(serializers.Serializer):
"""Project-scoped reference to a related work item."""
project_id = serializers.UUIDField(help_text="Project containing the related work item")
issue_id = serializers.UUIDField(help_text="ID of the related work item")
class IssueRelationResponseSerializer(serializers.Serializer):
"""
Serializer for issue relations response showing grouped relation types.
Returns issue IDs organized by relation type for efficient client-side processing.
Each list contains project_id and issue_id pairs so clients can resolve
cross-project relations.
"""
blocking = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of issue IDs that are blocking this issue",
child=IssueRelationRefSerializer(),
help_text="Work items blocking this issue",
)
blocked_by = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of issue IDs that this issue is blocked by",
child=IssueRelationRefSerializer(),
help_text="Work items this issue is blocked by",
)
duplicate = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of issue IDs that are duplicates of this issue",
child=IssueRelationRefSerializer(),
help_text="Duplicate work items",
)
relates_to = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of issue IDs that relate to this issue",
child=IssueRelationRefSerializer(),
help_text="Related work items",
)
start_after = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of issue IDs that start after this issue",
child=IssueRelationRefSerializer(),
help_text="Work items that start after this issue",
)
start_before = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of issue IDs that start before this issue",
child=IssueRelationRefSerializer(),
help_text="Work items that start before this issue",
)
finish_after = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of issue IDs that finish after this issue",
child=IssueRelationRefSerializer(),
help_text="Work items that finish after this issue",
)
finish_before = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of issue IDs that finish before this issue",
child=IssueRelationRefSerializer(),
help_text="Work items that finish before this issue",
)
+4 -3
View File
@@ -17,6 +17,7 @@ from drf_spectacular.utils import OpenApiExample, OpenApiRequest
# Module Imports
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from plane.settings.storage import S3Storage
from plane.utils.path_validator import sanitize_filename
from plane.db.models import FileAsset, User, Workspace
from plane.api.views.base import BaseAPIView
from plane.api.serializers import (
@@ -114,7 +115,7 @@ class UserAssetEndpoint(BaseAPIView):
This endpoint generates the necessary credentials for direct S3 upload.
"""
# get the asset key
name = request.data.get("name")
name = sanitize_filename(request.data.get("name")) or "unnamed"
type = request.data.get("type", "image/jpeg")
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
entity_type = request.data.get("entity_type", False)
@@ -287,7 +288,7 @@ class UserServerAssetEndpoint(BaseAPIView):
necessary credentials for direct S3 upload with server-side authentication.
"""
# get the asset key
name = request.data.get("name")
name = sanitize_filename(request.data.get("name")) or "unnamed"
type = request.data.get("type", "image/jpeg")
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
entity_type = request.data.get("entity_type", False)
@@ -498,7 +499,7 @@ class GenericAssetEndpoint(BaseAPIView):
Create a presigned URL for uploading generic assets that can be bound to entities like work items.
Supports various file types and includes external source tracking for integrations.
"""
name = request.data.get("name")
name = sanitize_filename(request.data.get("name"))
type = request.data.get("type")
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
project_id = request.data.get("project_id")
+98 -40
View File
@@ -23,11 +23,8 @@ from django.db.models import (
Value,
When,
Subquery,
UUIDField,
)
from django.db.models.functions import Coalesce
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.utils import timezone
from django.conf import settings
@@ -82,6 +79,7 @@ from plane.db.models import (
Workspace,
)
from plane.settings.storage import S3Storage
from plane.utils.path_validator import sanitize_filename
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from .base import BaseAPIView
from plane.utils.host import base_host
@@ -1861,7 +1859,7 @@ class IssueAttachmentListCreateAPIEndpoint(BaseAPIView):
status=status.HTTP_403_FORBIDDEN,
)
name = request.data.get("name")
name = sanitize_filename(request.data.get("name"))
type = request.data.get("type", False)
size = request.data.get("size")
external_id = request.data.get("external_id")
@@ -2292,14 +2290,35 @@ class IssueRelationListCreateAPIEndpoint(BaseAPIView):
name="Work Item Relations Response",
value={
"blocking": [
"550e8400-e29b-41d4-a716-446655440000",
"550e8400-e29b-41d4-a716-446655440001",
{
"project_id": "550e8400-e29b-41d4-a716-446655440010",
"issue_id": "550e8400-e29b-41d4-a716-446655440000",
},
{
"project_id": "550e8400-e29b-41d4-a716-446655440010",
"issue_id": "550e8400-e29b-41d4-a716-446655440001",
},
],
"blocked_by": [
{
"project_id": "550e8400-e29b-41d4-a716-446655440011",
"issue_id": "550e8400-e29b-41d4-a716-446655440002",
},
],
"blocked_by": ["550e8400-e29b-41d4-a716-446655440002"],
"duplicate": [],
"relates_to": ["550e8400-e29b-41d4-a716-446655440003"],
"relates_to": [
{
"project_id": "550e8400-e29b-41d4-a716-446655440010",
"issue_id": "550e8400-e29b-41d4-a716-446655440003",
},
],
"start_after": [],
"start_before": ["550e8400-e29b-41d4-a716-446655440004"],
"start_before": [
{
"project_id": "550e8400-e29b-41d4-a716-446655440012",
"issue_id": "550e8400-e29b-41d4-a716-446655440004",
},
],
"finish_after": [],
"finish_before": [],
},
@@ -2316,42 +2335,81 @@ class IssueRelationListCreateAPIEndpoint(BaseAPIView):
Retrieve all relationships for a work item organized by relation type.
Returns a structured response with relations grouped by type.
"""
empty_uuid_array = Value([], output_field=ArrayField(UUIDField()))
def _agg_ids(field, **filter_kwargs):
return Coalesce(
ArrayAgg(field, filter=Q(**filter_kwargs), distinct=True),
empty_uuid_array,
)
issue_relation_qs = IssueRelation.objects.filter(
relations = IssueRelation.objects.filter(
Q(issue_id=issue_id) | Q(related_issue_id=issue_id),
workspace__slug=slug,
)
relation_ids = issue_relation_qs.aggregate(
blocking_ids=_agg_ids("issue_id", relation_type="blocked_by", related_issue_id=issue_id),
blocked_by_ids=_agg_ids("related_issue_id", relation_type="blocked_by", issue_id=issue_id),
duplicate_ids=_agg_ids("related_issue_id", relation_type="duplicate", issue_id=issue_id),
duplicate_ids_related=_agg_ids("issue_id", relation_type="duplicate", related_issue_id=issue_id),
relates_to_ids=_agg_ids("related_issue_id", relation_type="relates_to", issue_id=issue_id),
relates_to_ids_related=_agg_ids("issue_id", relation_type="relates_to", related_issue_id=issue_id),
start_after_ids=_agg_ids("issue_id", relation_type="start_before", related_issue_id=issue_id),
start_before_ids=_agg_ids("related_issue_id", relation_type="start_before", issue_id=issue_id),
finish_after_ids=_agg_ids("issue_id", relation_type="finish_before", related_issue_id=issue_id),
finish_before_ids=_agg_ids("related_issue_id", relation_type="finish_before", issue_id=issue_id),
).values(
"relation_type",
"issue_id",
"related_issue_id",
issue_project_id=F("issue__project_id"),
related_issue_project_id=F("related_issue__project_id"),
)
response_data = {
"blocking": relation_ids["blocking_ids"],
"blocked_by": relation_ids["blocked_by_ids"],
"duplicate": list(set(relation_ids["duplicate_ids"] + relation_ids["duplicate_ids_related"])),
"relates_to": list(set(relation_ids["relates_to_ids"] + relation_ids["relates_to_ids_related"])),
"start_after": relation_ids["start_after_ids"],
"start_before": relation_ids["start_before_ids"],
"finish_after": relation_ids["finish_after_ids"],
"finish_before": relation_ids["finish_before_ids"],
"blocking": [],
"blocked_by": [],
"duplicate": [],
"relates_to": [],
"start_after": [],
"start_before": [],
"finish_after": [],
"finish_before": [],
}
seen_duplicate = set()
seen_relates_to = set()
for rel in relations:
rt = rel["relation_type"]
if rt == "blocked_by":
if str(rel["related_issue_id"]) == str(issue_id):
response_data["blocking"].append(
{"project_id": str(rel["issue_project_id"]), "issue_id": str(rel["issue_id"])}
)
if str(rel["issue_id"]) == str(issue_id):
response_data["blocked_by"].append(
{"project_id": str(rel["related_issue_project_id"]), "issue_id": str(rel["related_issue_id"])}
)
elif rt == "duplicate":
if str(rel["issue_id"]) == str(issue_id) and rel["related_issue_id"] not in seen_duplicate:
seen_duplicate.add(rel["related_issue_id"])
response_data["duplicate"].append(
{"project_id": str(rel["related_issue_project_id"]), "issue_id": str(rel["related_issue_id"])}
)
if str(rel["related_issue_id"]) == str(issue_id) and rel["issue_id"] not in seen_duplicate:
seen_duplicate.add(rel["issue_id"])
response_data["duplicate"].append(
{"project_id": str(rel["issue_project_id"]), "issue_id": str(rel["issue_id"])}
)
elif rt == "relates_to":
if str(rel["issue_id"]) == str(issue_id) and rel["related_issue_id"] not in seen_relates_to:
seen_relates_to.add(rel["related_issue_id"])
response_data["relates_to"].append(
{"project_id": str(rel["related_issue_project_id"]), "issue_id": str(rel["related_issue_id"])}
)
if str(rel["related_issue_id"]) == str(issue_id) and rel["issue_id"] not in seen_relates_to:
seen_relates_to.add(rel["issue_id"])
response_data["relates_to"].append(
{"project_id": str(rel["issue_project_id"]), "issue_id": str(rel["issue_id"])}
)
elif rt == "start_before":
if str(rel["related_issue_id"]) == str(issue_id):
response_data["start_after"].append(
{"project_id": str(rel["issue_project_id"]), "issue_id": str(rel["issue_id"])}
)
if str(rel["issue_id"]) == str(issue_id):
response_data["start_before"].append(
{"project_id": str(rel["related_issue_project_id"]), "issue_id": str(rel["related_issue_id"])}
)
elif rt == "finish_before":
if str(rel["related_issue_id"]) == str(issue_id):
response_data["finish_after"].append(
{"project_id": str(rel["issue_project_id"]), "issue_id": str(rel["issue_id"])}
)
if str(rel["issue_id"]) == str(issue_id):
response_data["finish_before"].append(
{"project_id": str(rel["related_issue_project_id"]), "issue_id": str(rel["related_issue_id"])}
)
return Response(response_data, status=status.HTTP_200_OK)
+30 -54
View File
@@ -3,90 +3,66 @@
# See the LICENSE file for details.
# Python imports
import socket
import ipaddress
import logging
from urllib.parse import urlparse
# Third party imports
from rest_framework import serializers
# Django imports
from django.conf import settings
# Module imports
from .base import DynamicBaseSerializer
from plane.db.models import Webhook, WebhookLog
from plane.db.models.webhook import validate_domain, validate_schema
from plane.utils.ip_address import validate_url
logger = logging.getLogger(__name__)
class WebhookSerializer(DynamicBaseSerializer):
url = serializers.URLField(validators=[validate_schema, validate_domain])
def create(self, validated_data):
url = validated_data.get("url", None)
# Extract the hostname from the URL
hostname = urlparse(url).hostname
if not hostname:
raise serializers.ValidationError({"url": "Invalid URL: No hostname found."})
# Resolve the hostname to IP addresses
def _validate_webhook_url(self, url):
"""Validate a webhook URL against SSRF and disallowed domain rules."""
try:
ip_addresses = socket.getaddrinfo(hostname, None)
except socket.gaierror:
raise serializers.ValidationError({"url": "Hostname could not be resolved."})
validate_url(
url,
allowed_ips=settings.WEBHOOK_ALLOWED_IPS,
allowed_hosts=settings.WEBHOOK_ALLOWED_HOSTS,
)
except ValueError as e:
logger.warning("Webhook URL validation failed for %s: %s", url, e)
raise serializers.ValidationError({"url": "Invalid or disallowed webhook URL."})
if not ip_addresses:
raise serializers.ValidationError({"url": "No IP addresses found for the hostname."})
hostname = (urlparse(url).hostname or "").rstrip(".").lower()
for addr in ip_addresses:
ip = ipaddress.ip_address(addr[4][0])
if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."})
# Hosts explicitly trusted via WEBHOOK_ALLOWED_HOSTS bypass the
# disallowed-domain check — they're already trusted for SSRF, so
# the loop-back guard would only get in the way of legitimate
# sibling services that share a parent domain with Plane.
if hostname in settings.WEBHOOK_ALLOWED_HOSTS:
return
# Additional validation for multiple request domains and their subdomains
request = self.context.get("request")
disallowed_domains = ["plane.so"] # Add your disallowed domains here
disallowed_domains = list(settings.WEBHOOK_DISALLOWED_DOMAINS)
if request:
request_host = request.get_host().split(":")[0] # Remove port if present
request_host = request.get_host().split(":")[0].rstrip(".").lower()
disallowed_domains.append(request_host)
# Check if hostname is a subdomain or exact match of any disallowed domain
if any(hostname == domain or hostname.endswith("." + domain) for domain in disallowed_domains):
raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."})
def create(self, validated_data):
url = validated_data.get("url", None)
self._validate_webhook_url(url)
return Webhook.objects.create(**validated_data)
def update(self, instance, validated_data):
url = validated_data.get("url", None)
if url:
# Extract the hostname from the URL
hostname = urlparse(url).hostname
if not hostname:
raise serializers.ValidationError({"url": "Invalid URL: No hostname found."})
# Resolve the hostname to IP addresses
try:
ip_addresses = socket.getaddrinfo(hostname, None)
except socket.gaierror:
raise serializers.ValidationError({"url": "Hostname could not be resolved."})
if not ip_addresses:
raise serializers.ValidationError({"url": "No IP addresses found for the hostname."})
for addr in ip_addresses:
ip = ipaddress.ip_address(addr[4][0])
if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."})
# Additional validation for multiple request domains and their subdomains
request = self.context.get("request")
disallowed_domains = ["plane.so"] # Add your disallowed domains here
if request:
request_host = request.get_host().split(":")[0] # Remove port if present
disallowed_domains.append(request_host)
# Check if hostname is a subdomain or exact match of any disallowed domain
if any(hostname == domain or hostname.endswith("." + domain) for domain in disallowed_domains):
raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."})
self._validate_webhook_url(url)
return super().update(instance, validated_data)
class Meta:
+14 -41
View File
@@ -29,7 +29,7 @@ from plane.db.models import (
Module,
)
from plane.utils.analytics_plot import build_graph_plot
from plane.utils.analytics_plot import build_graph_plot, VALID_ANALYTICS_FIELDS, VALID_YAXIS
from plane.utils.issue_filters import issue_filters
from plane.app.permissions import allow_permission, ROLE
@@ -41,32 +41,15 @@ class AnalyticsEndpoint(BaseAPIView):
y_axis = request.GET.get("y_axis", False)
segment = request.GET.get("segment", False)
valid_xaxis_segment = [
"state_id",
"state__group",
"labels__id",
"assignees__id",
"estimate_point__value",
"issue_cycle__cycle_id",
"issue_module__module_id",
"priority",
"start_date",
"target_date",
"created_at",
"completed_at",
]
valid_yaxis = ["issue_count", "estimate"]
# Check for x-axis and y-axis as thery are required parameters
if not x_axis or not y_axis or x_axis not in valid_xaxis_segment or y_axis not in valid_yaxis:
if not x_axis or not y_axis or x_axis not in VALID_ANALYTICS_FIELDS or y_axis not in VALID_YAXIS:
return Response(
{"error": "x-axis and y-axis dimensions are required and the values should be valid"},
status=status.HTTP_400_BAD_REQUEST,
)
# If segment is present it cannot be same as x-axis
if segment and (segment not in valid_xaxis_segment or x_axis == segment):
if segment and (segment not in VALID_ANALYTICS_FIELDS or x_axis == segment):
return Response(
{"error": "Both segment and x axis cannot be same and segment should be valid"},
status=status.HTTP_400_BAD_REQUEST,
@@ -214,13 +197,20 @@ class SavedAnalyticEndpoint(BaseAPIView):
x_axis = analytic_view.query_dict.get("x_axis", False)
y_axis = analytic_view.query_dict.get("y_axis", False)
if not x_axis or not y_axis:
if not x_axis or not y_axis or x_axis not in VALID_ANALYTICS_FIELDS or y_axis not in VALID_YAXIS:
return Response(
{"error": "x-axis and y-axis dimensions are required"},
{"error": "x-axis and y-axis dimensions are required and the values should be valid"},
status=status.HTTP_400_BAD_REQUEST,
)
segment = request.GET.get("segment", False)
if segment and (segment not in VALID_ANALYTICS_FIELDS or x_axis == segment):
return Response(
{"error": "Both segment and x axis cannot be same and segment should be valid"},
status=status.HTTP_400_BAD_REQUEST,
)
distribution = build_graph_plot(queryset=queryset, x_axis=x_axis, y_axis=y_axis, segment=segment)
total_issues = queryset.count()
return Response(
@@ -236,32 +226,15 @@ class ExportAnalyticsEndpoint(BaseAPIView):
y_axis = request.data.get("y_axis", False)
segment = request.data.get("segment", False)
valid_xaxis_segment = [
"state_id",
"state__group",
"labels__id",
"assignees__id",
"estimate_point",
"issue_cycle__cycle_id",
"issue_module__module_id",
"priority",
"start_date",
"target_date",
"created_at",
"completed_at",
]
valid_yaxis = ["issue_count", "estimate"]
# Check for x-axis and y-axis as thery are required parameters
if not x_axis or not y_axis or x_axis not in valid_xaxis_segment or y_axis not in valid_yaxis:
if not x_axis or not y_axis or x_axis not in VALID_ANALYTICS_FIELDS or y_axis not in VALID_YAXIS:
return Response(
{"error": "x-axis and y-axis dimensions are required and the values should be valid"},
status=status.HTTP_400_BAD_REQUEST,
)
# If segment is present it cannot be same as x-axis
if segment and (segment not in valid_xaxis_segment or x_axis == segment):
if segment and (segment not in VALID_ANALYTICS_FIELDS or x_axis == segment):
return Response(
{"error": "Both segment and x axis cannot be same and segment should be valid"},
status=status.HTTP_400_BAD_REQUEST,
+21 -6
View File
@@ -18,10 +18,11 @@ from rest_framework.permissions import AllowAny
# Module imports
from ..base import BaseAPIView
from plane.db.models import FileAsset, Workspace, Project, User
from plane.db.models import FileAsset, Workspace, Project, User, WorkspaceMember
from plane.settings.storage import S3Storage
from plane.app.permissions import allow_permission, ROLE
from plane.utils.cache import invalidate_cache_directly
from plane.utils.path_validator import sanitize_filename
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from plane.throttles.asset import AssetRateThrottle
@@ -108,7 +109,7 @@ class UserAssetsV2Endpoint(BaseAPIView):
def post(self, request):
# get the asset key
name = request.data.get("name")
name = sanitize_filename(request.data.get("name")) or "unnamed"
type = request.data.get("type", "image/jpeg")
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
entity_type = request.data.get("entity_type", False)
@@ -311,8 +312,9 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
else:
return
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def post(self, request, slug):
name = request.data.get("name")
name = sanitize_filename(request.data.get("name")) or "unnamed"
type = request.data.get("type", "image/jpeg")
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
entity_type = request.data.get("entity_type")
@@ -376,6 +378,7 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
status=status.HTTP_200_OK,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def patch(self, request, slug, asset_id):
# get the asset id
asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug)
@@ -397,6 +400,7 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
asset.save(update_fields=["is_uploaded", "attributes"])
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def delete(self, request, slug, asset_id):
asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug)
asset.is_deleted = True
@@ -406,6 +410,7 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
asset.save(update_fields=["is_deleted", "deleted_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def get(self, request, slug, asset_id):
# get the asset id
asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug)
@@ -511,7 +516,7 @@ class ProjectAssetEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def post(self, request, slug, project_id):
name = request.data.get("name")
name = sanitize_filename(request.data.get("name")) or "unnamed"
type = request.data.get("type", "image/jpeg")
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
entity_type = request.data.get("entity_type", "")
@@ -752,12 +757,22 @@ class DuplicateAssetEndpoint(BaseAPIView):
return Response({"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND)
storage = S3Storage(request=request)
original_asset = FileAsset.objects.filter(id=asset_id, is_uploaded=True).first()
# Scope the source asset lookup to workspaces the caller is a member of
user_workspace_ids = WorkspaceMember.objects.filter(
member=request.user,
is_active=True,
).values_list("workspace_id", flat=True)
original_asset = FileAsset.objects.filter(
id=asset_id,
is_uploaded=True,
workspace_id__in=user_workspace_ids,
).first()
if not original_asset:
return Response({"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND)
destination_key = f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}"
sanitized_name = sanitize_filename(original_asset.attributes.get("name")) or "unnamed"
destination_key = f"{workspace.id}/{uuid.uuid4().hex}-{sanitized_name}"
duplicated_asset = FileAsset.objects.create(
attributes={
"name": original_asset.attributes.get("name"),
+2 -1
View File
@@ -24,6 +24,7 @@ from plane.db.models import FileAsset, Workspace
from plane.bgtasks.issue_activities_task import issue_activity
from plane.app.permissions import allow_permission, ROLE
from plane.settings.storage import S3Storage
from plane.utils.path_validator import sanitize_filename
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from plane.utils.host import base_host
@@ -97,7 +98,7 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def post(self, request, slug, project_id, issue_id):
name = request.data.get("name")
name = sanitize_filename(request.data.get("name")) or "unnamed"
type = request.data.get("type", False)
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
+100 -69
View File
@@ -7,7 +7,7 @@ import json
# Django imports
from django.utils import timezone
from django.db.models import OuterRef, Func, F, Q, Value, UUIDField, Subquery
from django.db.models import OuterRef, Func, F, Q, Value, UUIDField, Subquery, Count, IntegerField
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.contrib.postgres.aggregates import ArrayAgg
@@ -22,7 +22,7 @@ from rest_framework import status
from .. import BaseAPIView
from plane.app.serializers import IssueSerializer
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import Issue, IssueLink, FileAsset, CycleIssue
from plane.db.models import Issue, IssueLink, FileAsset, CycleIssue, IssueLabel, IssueAssignee, ModuleIssue
from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.timezone_converter import user_timezone_converter
from collections import defaultdict
@@ -37,70 +37,97 @@ class SubIssuesEndpoint(BaseAPIView):
def get(self, request, slug, project_id, issue_id):
sub_issues = (
Issue.issue_objects.filter(parent_id=issue_id, workspace__slug=slug)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1]
)
)
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=FileAsset.objects.filter(
issue_id=OuterRef("id"),
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
link_count=Coalesce(
Subquery(
IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.values("issue")
.annotate(count=Count("id"))
.values("count"),
output_field=IntegerField(),
),
0,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
attachment_count=Coalesce(
Subquery(
FileAsset.objects.filter(
issue_id=OuterRef("id"),
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
)
.order_by()
.values("issue_id")
.annotate(count=Count("id"))
.values("count"),
output_field=IntegerField(),
),
0,
)
)
.annotate(
sub_issues_count=Coalesce(
Subquery(
Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.values("parent")
.annotate(count=Count("id"))
.values("count"),
output_field=IntegerField(),
),
0,
)
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=Q(~Q(labels__id__isnull=True) & Q(label_issue__deleted_at__isnull=True)),
Subquery(
IssueLabel.objects.filter(issue_id=OuterRef("id"), deleted_at__isnull=True)
.order_by()
.values("issue_id")
.annotate(arr=ArrayAgg("label_id", distinct=True))
.values("arr"),
output_field=ArrayField(UUIDField()),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=Q(
~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True)
& Q(issue_assignee__deleted_at__isnull=True)
),
Subquery(
IssueAssignee.objects.filter(
issue_id=OuterRef("id"),
assignee__member_project__is_active=True,
deleted_at__isnull=True,
)
.order_by()
.values("issue_id")
.annotate(arr=ArrayAgg("assignee_id", distinct=True))
.values("arr"),
output_field=ArrayField(UUIDField()),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=Q(
~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True)
& Q(issue_module__deleted_at__isnull=True)
),
Subquery(
ModuleIssue.objects.filter(
issue_id=OuterRef("id"),
module__archived_at__isnull=True,
deleted_at__isnull=True,
)
.order_by()
.values("issue_id")
.annotate(arr=ArrayAgg("module_id", distinct=True))
.values("arr"),
output_field=ArrayField(UUIDField()),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.annotate(state_group=F("state__group"))
.order_by("-created_at")
)
# Ordering
@@ -110,38 +137,42 @@ class SubIssuesEndpoint(BaseAPIView):
if order_by_param:
sub_issues, order_by_param = order_issue_queryset(sub_issues, order_by_param)
sub_issues = list(
sub_issues.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
"state_group",
)
)
# create's a dict with state group name with their respective issue id's
result = defaultdict(list)
for sub_issue in sub_issues:
result[sub_issue.state_group].append(str(sub_issue.id))
result[sub_issue["state_group"]].append(str(sub_issue["id"]))
sub_issues = sub_issues.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
)
datetime_fields = ["created_at", "updated_at"]
sub_issues = user_timezone_converter(sub_issues, datetime_fields, request.user.user_timezone)
# Grouping
+8
View File
@@ -52,6 +52,7 @@ from plane.db.models import (
from plane.license.utils.instance_value import get_email_configuration
from plane.utils.email import generate_plain_text_from_html
from plane.utils.exception_logger import log_exception
from plane.utils.ip_address import validate_url
from plane.settings.mongo import MongoConnection
@@ -325,6 +326,13 @@ def webhook_send_task(
return
try:
# Re-validate the webhook URL at send time to prevent DNS-rebinding attacks
validate_url(
webhook.url,
allowed_ips=settings.WEBHOOK_ALLOWED_IPS,
allowed_hosts=settings.WEBHOOK_ALLOWED_HOSTS,
)
# Send the webhook event
response = requests.post(webhook.url, headers=headers, json=payload, timeout=30)
+3
View File
@@ -11,10 +11,13 @@ from django.core.exceptions import ValidationError
from django.db import models
# Module import
from plane.utils.path_validator import sanitize_filename
from .base import BaseModel
def get_upload_path(instance, filename):
filename = sanitize_filename(filename) or uuid4().hex
if instance.workspace_id is not None:
return f"{instance.workspace.id}/{uuid4().hex}-{filename}"
return f"user-{uuid4().hex}-{filename}"
+2
View File
@@ -17,6 +17,7 @@ from django import apps
# Module imports
from plane.utils.html_processor import strip_tags
from plane.utils.path_validator import sanitize_filename
from plane.db.mixins import SoftDeletionManager
from plane.utils.exception_logger import log_exception
from .project import ProjectBaseModel
@@ -376,6 +377,7 @@ class IssueLink(ProjectBaseModel):
def get_upload_path(instance, filename):
filename = sanitize_filename(filename) or uuid4().hex
return f"{instance.workspace.id}/{uuid4().hex}-{filename}"
@@ -45,7 +45,8 @@ class InstanceConfigurationEndpoint(BaseAPIView):
bulk_configurations = []
for configuration in configurations:
value = request.data.get(configuration.key, configuration.value)
raw_value = request.data.get(configuration.key, configuration.value)
value = "" if raw_value is None else str(raw_value).strip()
if configuration.is_encrypted:
configuration.value = encrypt_data(value)
else:
@@ -63,8 +63,6 @@ class InstanceEndpoint(BaseAPIView):
POSTHOG_HOST,
UNSPLASH_ACCESS_KEY,
LLM_API_KEY,
IS_INTERCOM_ENABLED,
INTERCOM_APP_ID,
) = get_configuration_value(
[
{
@@ -124,15 +122,6 @@ class InstanceEndpoint(BaseAPIView):
"key": "LLM_API_KEY",
"default": os.environ.get("LLM_API_KEY", ""),
},
# Intercom settings
{
"key": "IS_INTERCOM_ENABLED",
"default": os.environ.get("IS_INTERCOM_ENABLED", "1"),
},
{
"key": "INTERCOM_APP_ID",
"default": os.environ.get("INTERCOM_APP_ID", ""),
},
]
)
@@ -169,10 +158,6 @@ class InstanceEndpoint(BaseAPIView):
# is smtp configured
data["is_smtp_configured"] = bool(EMAIL_HOST)
# Intercom settings
data["is_intercom_enabled"] = IS_INTERCOM_ENABLED == "1"
data["intercom_app_id"] = INTERCOM_APP_ID
# Base URL
data["admin_base_url"] = settings.ADMIN_BASE_URL
data["space_base_url"] = settings.SPACE_BASE_URL
+40
View File
@@ -5,6 +5,8 @@
"""Global Settings"""
# Python imports
import ipaddress
import logging
import os
from urllib.parse import urlparse
from urllib.parse import urljoin
@@ -32,6 +34,44 @@ DEBUG = int(os.environ.get("DEBUG", "0"))
# Self-hosted mode
IS_SELF_MANAGED = True
# Webhook IP allowlist — comma-separated IPs or CIDR ranges that are allowed as
# webhook targets even if they resolve to private networks.
# Example: "10.0.0.0/8,192.168.1.0/24,172.16.0.5"
_webhook_allowed_ips_raw = os.environ.get("WEBHOOK_ALLOWED_IPS", "")
WEBHOOK_ALLOWED_IPS = []
_logger = logging.getLogger("plane")
for _cidr in _webhook_allowed_ips_raw.split(","):
_cidr = _cidr.strip()
if not _cidr:
continue
try:
WEBHOOK_ALLOWED_IPS.append(ipaddress.ip_network(_cidr, strict=False))
except ValueError:
_logger.warning("WEBHOOK_ALLOWED_IPS: skipping invalid entry %r", _cidr)
# Webhook hostname allowlist — comma-separated hostnames that bypass the
# private-IP SSRF check. Useful for trusted internal services whose IPs are
# dynamic in containerised deployments (e.g. docker-compose service DNS,
# kubernetes service hostnames).
# Example: "silo,silo.namespace.svc.cluster.local,internal-api.lan"
_webhook_allowed_hosts_raw = os.environ.get("WEBHOOK_ALLOWED_HOSTS", "")
WEBHOOK_ALLOWED_HOSTS = [
_host.strip().rstrip(".").lower()
for _host in _webhook_allowed_hosts_raw.split(",")
if _host.strip()
]
# Webhook disallowed domains — comma-separated hostnames. Webhooks targeting
# these domains or any of their subdomains are rejected (the request host is
# always appended at validation time as a loop-back guard). Empty by default
# for self-hosted deployments; set to e.g. "plane.so" to block specific domains.
_webhook_disallowed_domains_raw = os.environ.get("WEBHOOK_DISALLOWED_DOMAINS", "")
WEBHOOK_DISALLOWED_DOMAINS = [
_d.strip().rstrip(".").lower()
for _d in _webhook_disallowed_domains_raw.split(",")
if _d.strip()
]
# Allowed Hosts
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "*").split(",")
+2 -1
View File
@@ -18,6 +18,7 @@ from rest_framework.response import Response
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from plane.db.models import DeployBoard, FileAsset
from plane.settings.storage import S3Storage
from plane.utils.path_validator import sanitize_filename
# Module imports
from .base import BaseAPIView
@@ -73,7 +74,7 @@ class EntityAssetEndpoint(BaseAPIView):
return Response({"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND)
# Get the asset
name = request.data.get("name")
name = sanitize_filename(request.data.get("name")) or "unnamed"
type = request.data.get("type", "image/jpeg")
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
entity_type = request.data.get("entity_type", "")
@@ -2,9 +2,12 @@
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import ipaddress
import pytest
from unittest.mock import patch, MagicMock
from plane.bgtasks.work_item_link_task import safe_get, validate_url_ip
from plane.utils.ip_address import validate_url
def _make_response(status_code=200, headers=None, is_redirect=False, content=b""):
@@ -43,6 +46,91 @@ class TestValidateUrlIp:
validate_url_ip("https://example.com") # Should not raise
@pytest.mark.unit
class TestValidateUrlAllowlist:
"""Test validate_url allowlist permits specific private IPs."""
def test_allowlist_permits_private_ip(self):
allowed = [ipaddress.ip_network("192.168.1.0/24")]
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("192.168.1.50", 0))]
validate_url("http://example.com", allowed_ips=allowed) # Should not raise
def test_allowlist_does_not_permit_other_private_ip(self):
allowed = [ipaddress.ip_network("192.168.1.0/24")]
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("10.0.0.1", 0))]
with pytest.raises(ValueError, match="private/internal"):
validate_url("http://example.com", allowed_ips=allowed)
def test_allowlist_permits_loopback_when_explicitly_allowed(self):
allowed = [ipaddress.ip_network("127.0.0.0/8")]
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("127.0.0.1", 0))]
validate_url("http://example.com", allowed_ips=allowed) # Should not raise
def test_allowlist_permits_matching_ipv4_with_mixed_version_networks(self):
allowed = [
ipaddress.ip_network("2001:db8::/32"),
ipaddress.ip_network("192.168.1.0/24"),
]
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("192.168.1.50", 0))]
validate_url("http://example.com", allowed_ips=allowed) # Should not raise
def test_allowlist_blocks_non_matching_ipv4_with_mixed_version_networks(self):
allowed = [
ipaddress.ip_network("2001:db8::/32"),
ipaddress.ip_network("192.168.1.0/24"),
]
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("10.0.0.1", 0))]
with pytest.raises(ValueError, match="private/internal"):
validate_url("http://example.com", allowed_ips=allowed)
def test_allowed_hosts_bypasses_private_ip_check(self):
"""Hostnames in WEBHOOK_ALLOWED_HOSTS skip IP-based blocking — used for
trusted internal services (e.g. Silo) whose IPs are dynamic in
containerised deployments."""
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("172.18.0.5", 0))]
validate_url("http://silo:3000/hook", allowed_hosts=["silo"]) # Should not raise
def test_allowed_hosts_matches_case_insensitively(self):
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("10.0.0.1", 0))]
validate_url(
"http://Silo.Namespace.Svc.Cluster.Local/x",
allowed_hosts=["silo.namespace.svc.cluster.local"],
) # Should not raise
def test_allowed_hosts_skips_dns_lookup(self):
"""When the hostname is explicitly trusted we shouldn't even resolve it —
protects against operators who allowlist a name that isn't resolvable
from the API container."""
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
validate_url("http://silo/hook", allowed_hosts=["silo"])
mock_dns.assert_not_called()
def test_allowed_hosts_requires_exact_match(self):
"""Subdomains of an allowed host must NOT bypass — a hostile
``attacker.silo.internal`` should still be blocked when only
``silo.internal`` is allowed."""
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("192.168.1.1", 0))]
with pytest.raises(ValueError, match="private/internal"):
validate_url(
"http://attacker.silo.internal/x",
allowed_hosts=["silo.internal"],
)
def test_allowed_hosts_empty_does_not_bypass(self):
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("10.0.0.1", 0))]
with pytest.raises(ValueError, match="private/internal"):
validate_url("http://silo/hook", allowed_hosts=[])
@pytest.mark.unit
class TestSafeGet:
"""Test safe_get follows redirects safely and blocks SSRF."""
+26
View File
@@ -22,6 +22,23 @@ from django.utils import timezone
# Module imports
from plane.db.models import Issue, Project
VALID_ANALYTICS_FIELDS = [
"state_id",
"state__group",
"labels__id",
"assignees__id",
"estimate_point__value",
"issue_cycle__cycle_id",
"issue_module__module_id",
"priority",
"start_date",
"target_date",
"created_at",
"completed_at",
]
VALID_YAXIS = ["issue_count", "estimate"]
def annotate_with_monthly_dimension(queryset, field_name, attribute):
# Get the year and the months
@@ -34,6 +51,8 @@ def annotate_with_monthly_dimension(queryset, field_name, attribute):
def extract_axis(queryset, x_axis):
if x_axis not in VALID_ANALYTICS_FIELDS:
raise ValueError(f"Invalid x_axis value: {x_axis}")
# Format the dimension when the axis is in date
if x_axis in ["created_at", "start_date", "target_date", "completed_at"]:
queryset = annotate_with_monthly_dimension(queryset, x_axis, "dimension")
@@ -52,6 +71,13 @@ def sort_data(data, temp_axis):
def build_graph_plot(queryset, x_axis, y_axis, segment=None):
if x_axis not in VALID_ANALYTICS_FIELDS:
raise ValueError(f"Invalid x_axis value: {x_axis}")
if y_axis not in VALID_YAXIS:
raise ValueError(f"Invalid y_axis value: {y_axis}")
if segment and segment not in VALID_ANALYTICS_FIELDS:
raise ValueError(f"Invalid segment value: {segment}")
# temp x_axis
temp_axis = x_axis
# Extract the x_axis and queryset
@@ -232,21 +232,6 @@ unsplash_config_variables = [
},
]
intercom_config_variables = [
{
"key": "IS_INTERCOM_ENABLED",
"value": os.environ.get("IS_INTERCOM_ENABLED", "1"),
"category": "INTERCOM",
"is_encrypted": False,
},
{
"key": "INTERCOM_APP_ID",
"value": os.environ.get("INTERCOM_APP_ID", ""),
"category": "INTERCOM",
"is_encrypted": False,
},
]
core_config_variables = [
*authentication_config_variables,
*workspace_management_config_variables,
@@ -257,5 +242,4 @@ core_config_variables = [
*smtp_config_variables,
*llm_config_variables,
*unsplash_config_variables,
*intercom_config_variables,
]
+57
View File
@@ -2,6 +2,63 @@
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Python imports
import ipaddress
import socket
from urllib.parse import urlparse
def validate_url(url, allowed_ips=None, allowed_hosts=None):
"""
Validate that a URL doesn't resolve to a private/internal IP address (SSRF protection).
Args:
url: The URL to validate.
allowed_ips: Optional list of ipaddress.ip_network objects. IPs falling within
these networks are permitted even if they are private/loopback/reserved.
Typically sourced from the WEBHOOK_ALLOWED_IPS setting.
allowed_hosts: Optional iterable of hostnames that bypass IP-based blocking
(exact, case-insensitive match against the URL hostname).
Typically sourced from the WEBHOOK_ALLOWED_HOSTS setting and
used for trusted internal services (e.g. Silo) whose IPs are
dynamic in containerised deployments.
Raises:
ValueError: If the URL is invalid or resolves to a blocked IP.
"""
parsed = urlparse(url)
hostname = parsed.hostname
if not hostname:
raise ValueError("Invalid URL: No hostname found")
if parsed.scheme not in ("http", "https"):
raise ValueError("Invalid URL scheme. Only HTTP and HTTPS are allowed")
normalized_host = hostname.rstrip(".").lower()
if allowed_hosts and normalized_host in {
(h or "").rstrip(".").lower() for h in allowed_hosts if h
}:
return
try:
addr_info = socket.getaddrinfo(hostname, None)
except socket.gaierror:
raise ValueError("Hostname could not be resolved")
if not addr_info:
raise ValueError("No IP addresses found for the hostname")
for addr in addr_info:
ip = ipaddress.ip_address(addr[4][0])
if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
if allowed_ips and any(
network.version == ip.version and ip in network for network in allowed_ips
):
continue
raise ValueError("Access to private/internal networks is not allowed")
def get_client_ip(request):
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
if x_forwarded_for:
+41
View File
@@ -7,9 +7,50 @@ from django.utils.http import url_has_allowed_host_and_scheme
from django.conf import settings
# Python imports
import os
from urllib.parse import urlparse
def sanitize_filename(filename):
"""
Sanitize a filename to prevent path traversal attacks.
Strips directory components, path traversal sequences, and null bytes
from user-supplied filenames used in upload paths and S3 object keys.
Returns None for empty/missing input so callers can still validate
that a filename was provided.
"""
if not filename or not isinstance(filename, str):
return None
# Strip null bytes
filename = filename.replace("\x00", "")
# Normalize backslashes so os.path.basename handles Windows-style paths on POSIX
filename = filename.replace("\\", "/")
# Take only the basename to remove any directory components
filename = os.path.basename(filename)
# Remove any remaining path traversal sequences
filename = filename.replace("..", "")
# Strip whitespace before removing leading dots so " .env" is caught
filename = filename.strip()
# Remove leading dots (hidden files)
filename = filename.lstrip(".")
# Strip any remaining whitespace
filename = filename.strip()
if not filename:
return None
return filename
def _contains_suspicious_patterns(path: str) -> bool:
"""
Check for suspicious patterns that might indicate malicious intent.
+3 -3
View File
@@ -1,7 +1,7 @@
# base requirements
# django
Django==4.2.29
Django==4.2.30
# rest framework
djangorestframework==3.15.2
# postgres
@@ -51,9 +51,9 @@ beautifulsoup4==4.12.3
# analytics
posthog==3.5.0
# crypto
cryptography==46.0.6
cryptography==46.0.7
# html validator
lxml==6.0.0
lxml==6.1.0
# s3
boto3==1.34.96
# password validator
+1 -1
View File
@@ -1,6 +1,6 @@
-r base.txt
# test framework
pytest==9.0.2
pytest==9.0.3
pytest-django==4.5.2
pytest-cov==4.1.0
pytest-xdist==3.3.1
@@ -261,7 +261,7 @@
<td height="5" width="8" style=" font-size: 5px; line-height: 5px; " > ­ </td>
</tr>
<tr>
<td class="r26-i" style=" font-size: 0px; line-height: 0px; " > <a href="https://twitter.com/planepowers" target="_blank" style=" color: #0092ff; text-decoration: underline; " > <img src="{{ current_site }}/static/logos/twitter_32px.png" width="32" border="0" class="" style=" display: block; width: 100%; " /></a> </td>
<td class="r26-i" style=" font-size: 0px; line-height: 0px; " > <a href="https://x.com/planepowers" target="_blank" style=" color: #0092ff; text-decoration: underline; " > <img src="{{ current_site }}/static/logos/twitter_32px.png" width="32" border="0" class="" style=" display: block; width: 100%; " /></a> </td>
<td class="nl2go-responsive-hide" width="8" style=" font-size: 0px; line-height: 1px; " > ­ </td>
</tr>
<tr class="nl2go-responsive-hide" >
@@ -233,7 +233,7 @@
<td>
<div style="font-size: 0.8rem; color: #1c2024">
This email was sent to <a href="mailto:{{receiver.email}}" style="color: #3a5bc7; font-weight: 500; text-decoration: none" >{{ receiver.email }}.</a > If you'd rather not receive this kind of email, <a href="{{ issue_url }}" style="color: #3a5bc7; text-decoration: none" >you can unsubscribe to the {{entity_type}}</a > or <a href="{{ user_preference }}" style="color: #3a5bc7; text-decoration: none" >manage your email preferences</a >. <!-- Github | LinkedIn | Twitter -->
<div style="margin-top: 60px; float: right"> <a href="https://github.com/makeplane" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/github_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> <a href="https://www.linkedin.com/company/planepowers/" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/linkedin_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> <a href="https://twitter.com/planepowers" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/twitter_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> </div>
<div style="margin-top: 60px; float: right"> <a href="https://github.com/makeplane" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/github_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> <a href="https://www.linkedin.com/company/planepowers/" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/linkedin_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> <a href="https://x.com/planepowers" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/twitter_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> </div>
</div>
</td>
</tr>
@@ -155,7 +155,7 @@
<td class="nl2go-responsive-hide" width="2" style=" font-size: 0px; line-height: 1px; background-color: #efefef; " > ­ </td>
<td align="left" valign="top" class="r21-i nl2go-default-textstyle" style=" color: #3b3f44; font-family: georgia, serif; font-size: 16px; word-break: break-word; line-height: 1.5; padding-bottom: 5px; padding-left: 5px; padding-right: 5px; padding-top: 5px; text-align: left; " >
<div>
<p style="margin: 0"> <span style="font-size: 13px" >Despite our popularity, we are humbly early-stage. We are shipping fast, so please reach out to us with feature requests, major and minor nits, and anything else you find missing. We read every </span ><a href="https://forum.plane.so" title="Plane Forum" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >message</span ></a ><span style="font-size: 13px" >, </span ><a href="http://twitter.com/planepowers" title="@planepowers" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >tweet</span ></a ><span style="font-size: 13px" >, and </span ><a href="https://github.com/makeplane/plane/issues" title="Plane's GitHub conversations" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >conversation</span ></a ><span style="font-size: 13px" > and update </span ><a href="https://plane.sh/plane/0b170a1c-0e55-47cb-9307-ea49a05672b5?board=kanban" title="Plane's roadmap" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >our public roadmap</span ></a ><span style="font-size: 13px" >.</span > </p>
<p style="margin: 0"> <span style="font-size: 13px" >Despite our popularity, we are humbly early-stage. We are shipping fast, so please reach out to us with feature requests, major and minor nits, and anything else you find missing. We read every </span ><a href="https://forum.plane.so" title="Plane Forum" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >message</span ></a ><span style="font-size: 13px" >, </span ><a href="https://x.com/planepowers" title="@planepowers" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >tweet</span ></a ><span style="font-size: 13px" >, and </span ><a href="https://github.com/makeplane/plane/issues" title="Plane's GitHub conversations" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >conversation</span ></a ><span style="font-size: 13px" > and update </span ><a href="https://plane.sh/plane/0b170a1c-0e55-47cb-9307-ea49a05672b5?board=kanban" title="Plane's roadmap" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >our public roadmap</span ></a ><span style="font-size: 13px" >.</span > </p>
</div>
</td>
<td class="nl2go-responsive-hide" width="2" style=" font-size: 0px; line-height: 1px; background-color: #efefef; " > ­ </td>
@@ -220,7 +220,7 @@
<th width="40" class="r25-c mobshow resp-table" style=" font-weight: normal; " >
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r26-o" style=" table-layout: fixed; width: 100%; " >
<tr>
<td class="r18-i" style=" font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px; " > <a href="https://twitter.com/planepowers" target="_blank" style=" color: #006399; text-decoration: underline; " > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/twitter_32px.png" width="32" border="0" style=" display: block; width: 100%; " /></a> </td>
<td class="r18-i" style=" font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px; " > <a href="https://x.com/planepowers" target="_blank" style=" color: #006399; text-decoration: underline; " > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/twitter_32px.png" width="32" border="0" style=" display: block; width: 100%; " /></a> </td>
<td class="nl2go-responsive-hide" width="8" style=" font-size: 0px; line-height: 1px; " > ­ </td>
</tr>
</table>
@@ -831,7 +831,7 @@
"
>
<a
href="https://twitter.com/planepowers"
href="https://x.com/planepowers"
target="_blank"
style="
color: #006399;
@@ -973,7 +973,7 @@
><span style="font-size: 13px"
>, </span
><a
href="http://twitter.com/planepowers"
href="https://x.com/planepowers"
title="@planepowers"
target="_blank"
style="
@@ -1344,7 +1344,7 @@
"
>
<a
href="https://twitter.com/planepowers"
href="https://x.com/planepowers"
target="_blank"
style="
color: #006399;
@@ -974,7 +974,7 @@
><span style="font-size: 13px"
>, </span
><a
href="http://twitter.com/planepowers"
href="https://x.com/planepowers"
title="@planepowers"
target="_blank"
style="
@@ -1345,7 +1345,7 @@
"
>
<a
href="https://twitter.com/planepowers"
href="https://x.com/planepowers"
target="_blank"
style="
color: #006399;
+1 -1
View File
@@ -3,7 +3,7 @@ FROM node:22-alpine AS base
# Setup pnpm package manager with corepack and configure global bin directory for caching
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH"
RUN corepack enable
# *****************************************************************************
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "live",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"description": "A realtime collaborative server powers Plane's rich text editor",
"license": "AGPL-3.0",
+1 -1
View File
@@ -4,7 +4,7 @@ WORKDIR /app
ENV TURBO_TELEMETRY_DISABLED=1
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH"
ENV CI=1
RUN corepack enable pnpm
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "space",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"license": "AGPL-3.0",
"type": "module",
+1 -1
View File
@@ -3,7 +3,7 @@ FROM node:22-alpine AS base
# Setup pnpm package manager with corepack and configure global bin directory for caching
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH"
RUN corepack enable
# *****************************************************************************
+14 -15
View File
@@ -12,6 +12,7 @@ import type { TOnboardingStep } from "@plane/types";
import { EOnboardingSteps } from "@plane/types";
import { cn } from "@plane/utils";
// hooks
import { useInstance } from "@/hooks/store/use-instance";
import { useUser } from "@/hooks/store/user";
// local imports
import { SwitchAccountDropdown } from "./switch-account-dropdown";
@@ -26,6 +27,8 @@ export const OnboardingHeader = observer(function OnboardingHeader(props: Onboar
const { currentStep, updateCurrentStep, hasInvitations } = props;
// store hooks
const { data: user } = useUser();
const { config: instanceConfig } = useInstance();
const isSelfManaged = instanceConfig?.is_self_managed;
// handle step back
const handleStepBack = () => {
@@ -37,7 +40,7 @@ export const OnboardingHeader = observer(function OnboardingHeader(props: Onboar
updateCurrentStep(EOnboardingSteps.ROLE_SETUP);
break;
case EOnboardingSteps.WORKSPACE_CREATE_OR_JOIN:
updateCurrentStep(EOnboardingSteps.USE_CASE_SETUP);
updateCurrentStep(isSelfManaged ? EOnboardingSteps.PROFILE_SETUP : EOnboardingSteps.USE_CASE_SETUP);
break;
}
};
@@ -45,22 +48,18 @@ export const OnboardingHeader = observer(function OnboardingHeader(props: Onboar
// can go back
const canGoBack = ![EOnboardingSteps.PROFILE_SETUP, EOnboardingSteps.INVITE_MEMBERS].includes(currentStep);
// Get current step number for progress tracking
const getCurrentStepNumber = (): number => {
const stepOrder: TOnboardingStep[] = [
EOnboardingSteps.PROFILE_SETUP,
EOnboardingSteps.ROLE_SETUP,
EOnboardingSteps.USE_CASE_SETUP,
...(hasInvitations
? [EOnboardingSteps.WORKSPACE_CREATE_OR_JOIN]
: [EOnboardingSteps.WORKSPACE_CREATE_OR_JOIN, EOnboardingSteps.INVITE_MEMBERS]),
];
return stepOrder.indexOf(currentStep) + 1;
};
// step order for progress tracking — include INVITE_MEMBERS if user is currently on it
const showInviteStep = !hasInvitations || currentStep === EOnboardingSteps.INVITE_MEMBERS;
const stepOrder: TOnboardingStep[] = [
EOnboardingSteps.PROFILE_SETUP,
...(isSelfManaged ? [] : [EOnboardingSteps.ROLE_SETUP, EOnboardingSteps.USE_CASE_SETUP]),
EOnboardingSteps.WORKSPACE_CREATE_OR_JOIN,
...(showInviteStep ? [EOnboardingSteps.INVITE_MEMBERS] : []),
];
// derived values
const currentStepNumber = getCurrentStepNumber();
const totalSteps = hasInvitations ? 4 : 5; // 4 if invites available, 5 if not
const currentStepNumber = stepOrder.indexOf(currentStep) + 1;
const totalSteps = stepOrder.length;
const userName = user?.display_name
? user?.display_name
: user?.first_name
+12 -2
View File
@@ -11,6 +11,7 @@ import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IWorkspaceMemberInvitation, TOnboardingStep, TOnboardingSteps, TUserProfile } from "@plane/types";
import { EOnboardingSteps } from "@plane/types";
// hooks
import { useInstance } from "@/hooks/store/use-instance";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUser, useUserProfile } from "@/hooks/store/user";
// local components
@@ -27,8 +28,10 @@ export const OnboardingRoot = observer(function OnboardingRoot({ invitations = [
const { data: user } = useUser();
const { data: userProfile, updateUserProfile, finishUserOnboarding } = useUserProfile();
const { workspaces } = useWorkspace();
const { config: instanceConfig } = useInstance();
const workspacesList = Object.values(workspaces ?? {});
const isSelfManaged = instanceConfig?.is_self_managed;
// Calculate total steps based on whether invitations are available
const hasInvitations = invitations.length > 0;
@@ -68,7 +71,14 @@ export const OnboardingRoot = observer(function OnboardingRoot({ invitations = [
(step: EOnboardingSteps, skipInvites?: boolean) => {
switch (step) {
case EOnboardingSteps.PROFILE_SETUP:
setCurrentStep(EOnboardingSteps.ROLE_SETUP);
if (isSelfManaged) {
// Skip role & use case steps for self-hosted
stepChange({ profile_complete: true });
if (workspacesList.length > 0) finishOnboarding();
else setCurrentStep(EOnboardingSteps.WORKSPACE_CREATE_OR_JOIN);
} else {
setCurrentStep(EOnboardingSteps.ROLE_SETUP);
}
break;
case EOnboardingSteps.ROLE_SETUP:
setCurrentStep(EOnboardingSteps.USE_CASE_SETUP);
@@ -91,7 +101,7 @@ export const OnboardingRoot = observer(function OnboardingRoot({ invitations = [
break;
}
},
[stepChange, finishOnboarding, workspacesList]
[stepChange, finishOnboarding, workspacesList, isSelfManaged]
);
const updateCurrentStep = (step: EOnboardingSteps) => setCurrentStep(step);
@@ -9,7 +9,6 @@ import { FileText, GithubIcon, MessageSquare, Rocket } from "lucide-react";
import type { TPowerKCommandConfig } from "@/components/power-k/core/types";
// hooks
import { usePowerK } from "@/hooks/store/use-power-k";
import { useChatSupport } from "@/hooks/use-chat-support";
/**
* Help commands - Help related commands
@@ -17,7 +16,6 @@ import { useChatSupport } from "@/hooks/use-chat-support";
export const usePowerKHelpCommands = (): TPowerKCommandConfig[] => {
// store
const { toggleShortcutsListModal } = usePowerK();
const { isEnabled: isChatSupportEnabled, openChatSupport } = useChatSupport();
return [
{
@@ -71,16 +69,5 @@ export const usePowerKHelpCommands = (): TPowerKCommandConfig[] => {
isVisible: () => true,
closeOnSelect: true,
},
{
id: "chat_with_us",
type: "action",
group: "help",
i18n_title: "power_k.help_actions.chat_with_us",
icon: MessageSquare,
action: () => openChatSupport(),
isEnabled: () => isChatSupportEnabled,
isVisible: () => isChatSupportEnabled,
closeOnSelect: true,
},
];
};
@@ -8,6 +8,7 @@ import { useState, Fragment } from "react";
import { Dialog, Transition } from "@headlessui/react";
// plane imports
import { CloseIcon, SearchIcon } from "@plane/propel/icons";
import { ScrollArea } from "@plane/propel/scrollarea";
import { Input } from "@plane/ui";
// hooks
import { usePowerK } from "@/hooks/store/use-power-k";
@@ -61,28 +62,33 @@ export function ShortcutsModal(props: Props) {
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative flex h-full items-center justify-center">
<div className="flex h-[61vh] w-full flex-col space-y-4 overflow-hidden rounded-lg bg-surface-1 p-5 shadow-raised-200 transition-all sm:w-[28rem]">
<Dialog.Title as="h3" className="flex justify-between">
<div className="flex h-[61vh] w-full flex-col space-y-4 overflow-hidden rounded-lg bg-surface-1 py-5 shadow-raised-200 transition-all sm:w-[28rem]">
<Dialog.Title as="h3" className="flex justify-between px-5">
<span className="text-16 font-medium">Keyboard shortcuts</span>
<button type="button" onClick={handleClose}>
<CloseIcon className="h-4 w-4 text-secondary hover:text-primary" aria-hidden="true" />
</button>
</Dialog.Title>
<div className="flex w-full items-center rounded-sm border-[0.5px] border-subtle bg-surface-2 px-2">
<SearchIcon className="h-3.5 w-3.5 text-secondary" />
<Input
id="search"
name="search"
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search for shortcuts"
className="w-full border-none bg-transparent py-1 text-11 text-secondary outline-none"
autoFocus
tabIndex={1}
/>
<div className="px-5">
<div className="flex w-full items-center rounded-sm border-[0.5px] border-subtle bg-surface-2 px-2">
<SearchIcon className="h-3.5 w-3.5 text-secondary" />
<Input
id="search"
name="search"
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search for shortcuts"
className="w-full border-none bg-transparent py-1 text-11 text-secondary outline-none"
autoFocus
tabIndex={1}
/>
</div>
</div>
<ShortcutRenderer searchQuery={query} commands={allCommandsWithShortcuts} />
<ScrollArea size="sm" rootClassName="overflow-y-scroll px-5">
<ShortcutRenderer searchQuery={query} commands={allCommandsWithShortcuts} />
</ScrollArea>
</div>
</Dialog.Panel>
</Transition.Child>
@@ -78,7 +78,7 @@ export function ShortcutRenderer(props: Props) {
const isShortcutsEmpty = groupedCommands.length === 0;
return (
<div className="flex flex-col gap-y-3 overflow-y-auto">
<div className="flex flex-col gap-y-3">
{!isShortcutsEmpty ? (
groupedCommands.map((group) => (
<div key={group.key}>
+1 -1
View File
@@ -418,7 +418,7 @@ export function ProjectDetailsForm(props: IProjectDetailsForm) {
onChange(value);
}}
error={Boolean(errors.timezone)}
buttonClassName="border-none"
buttonClassName="!border-subtle !shadow-none font-medium rounded-md"
disabled={!isAdmin}
/>
</>
@@ -6,7 +6,7 @@
import React, { useState } from "react";
import { observer } from "mobx-react";
import { HelpCircle, MessagesSquare, User } from "lucide-react";
import { HelpCircle, User } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { PageIcon } from "@plane/propel/icons";
// ui
@@ -16,7 +16,6 @@ import { ProductUpdatesModal } from "@/components/global";
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
// hooks
import { usePowerK } from "@/hooks/store/use-power-k";
import { useChatSupport } from "@/hooks/use-chat-support";
// plane web components
import { PlaneVersionNumber } from "@/plane-web/components/global";
@@ -24,7 +23,6 @@ export const HelpMenuRoot = observer(function HelpMenuRoot() {
// store hooks
const { t } = useTranslation();
const { toggleShortcutsListModal } = usePowerK();
const { openChatSupport, isEnabled: isChatSupportEnabled } = useChatSupport();
// states
const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false);
const [isProductUpdatesModalOpen, setProductUpdatesModalOpen] = useState(false);
@@ -56,18 +54,6 @@ export const HelpMenuRoot = observer(function HelpMenuRoot() {
<span className="text-11">{t("documentation")}</span>
</div>
</CustomMenu.MenuItem>
{isChatSupportEnabled && (
<CustomMenu.MenuItem>
<button
type="button"
onClick={openChatSupport}
className="flex w-full items-center gap-x-2 rounded-sm text-11 hover:bg-layer-1"
>
<MessagesSquare className="h-3.5 w-3.5 text-secondary" />
<span className="text-11">{t("message_support")}</span>
</button>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={() => window.open("mailto:sales@plane.so", "_blank")}>
<div className="flex items-center gap-x-2 rounded-sm text-11">
<User className="h-3.5 w-3.5 text-secondary" size={14} />
@@ -1,19 +0,0 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
type ChatSupportType = "open";
type ChatSupportEventType = `chat-support:${ChatSupportType}`;
export const CHAT_SUPPORT_EVENTS = {
open: "chat-support:open",
} satisfies Record<ChatSupportType, ChatSupportEventType>;
export class ChatSupportEvent extends CustomEvent<ChatSupportType> {
constructor(type: ChatSupportType) {
super(CHAT_SUPPORT_EVENTS[type]);
}
}
-31
View File
@@ -1,31 +0,0 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback } from "react";
// custom events
import { ChatSupportEvent } from "@/custom-events/chat-support";
// hooks
import { useInstance } from "@/hooks/store/use-instance";
import { useUser } from "@/hooks/store/user";
export interface IUseChatSupport {
openChatSupport: () => void;
isEnabled: boolean;
}
export const useChatSupport = (): IUseChatSupport => {
const { data: user } = useUser();
const { config } = useInstance();
// derived values
const isEnabled = Boolean(user && config?.is_intercom_enabled && config?.intercom_app_id);
const openChatSupport = useCallback(() => {
if (!isEnabled) return;
window.dispatchEvent(new ChatSupportEvent("open"));
}, [isEnabled]);
return { openChatSupport, isEnabled };
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"license": "AGPL-3.0",
"type": "module",
+9
View File
@@ -51,3 +51,12 @@ API_KEY_RATE_LIMIT=60/minute
# Live Server Secret Key
LIVE_SERVER_SECRET_KEY=htbqvBJAgpm9bzvf3r4urJer0ENReatceh
# Webhook IP allowlist — comma-separated IPs or CIDR ranges allowed as webhook targets
# even if they resolve to private networks (e.g. "10.0.0.0/8,192.168.1.0/24,172.16.0.5")
WEBHOOK_ALLOWED_IPS=
# Webhook hostname allowlist — comma-separated hostnames that bypass the private-IP
# SSRF check. Useful for trusted internal services whose container/service IPs are
# dynamic (e.g. "silo,silo.namespace.svc.cluster.local")
WEBHOOK_ALLOWED_HOSTS=
@@ -58,6 +58,8 @@ x-app-env: &app-env
API_KEY_RATE_LIMIT: ${API_KEY_RATE_LIMIT:-60/minute}
MINIO_ENDPOINT_SSL: ${MINIO_ENDPOINT_SSL:-0}
LIVE_SERVER_SECRET_KEY: ${LIVE_SERVER_SECRET_KEY:-2FiJk1U2aiVPEQtzLehYGlTSnTnrs7LW}
WEBHOOK_ALLOWED_IPS: ${WEBHOOK_ALLOWED_IPS:-}
WEBHOOK_ALLOWED_HOSTS: ${WEBHOOK_ALLOWED_HOSTS:-}
services:
web:
+9
View File
@@ -80,3 +80,12 @@ API_KEY_RATE_LIMIT=60/minute
# Live server environment variables
# WARNING: You must set a secure value for LIVE_SERVER_SECRET_KEY in production environments.
LIVE_SERVER_SECRET_KEY=
# Webhook IP allowlist — comma-separated IPs or CIDR ranges allowed as webhook targets
# even if they resolve to private networks (e.g. "10.0.0.0/8,192.168.1.0/24,172.16.0.5")
WEBHOOK_ALLOWED_IPS=
# Webhook hostname allowlist — comma-separated hostnames that bypass the private-IP
# SSRF check. Useful for trusted internal services whose container/service IPs are
# dynamic (e.g. "silo,silo.namespace.svc.cluster.local")
WEBHOOK_ALLOWED_HOSTS=
+7 -3
View File
@@ -1,6 +1,6 @@
{
"name": "plane",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"description": "Open-source project management that unlocks customer value",
"license": "AGPL-3.0",
@@ -62,7 +62,7 @@
"webpack": "5.104.1",
"lodash-es": "catalog:",
"@isaacs/brace-expansion": "5.0.1",
"lodash": "4.17.23",
"lodash": "4.18.1",
"markdown-it": "14.1.1",
"rollup": "4.59.0",
"minimatch@3": "3.1.4",
@@ -76,7 +76,11 @@
"yaml@1": "1.10.3",
"yaml@2": "2.8.3",
"path-to-regexp": "0.1.13",
"defu": "6.1.5"
"defu": "6.1.5",
"postcss": "8.5.10",
"axios": "catalog:",
"follow-redirects": "1.16.0",
"uuid": "catalog:"
},
"onlyBuiltDependencies": [
"@parcel/watcher",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/codemods",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"scripts": {
"test": "vitest run",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/constants",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"license": "AGPL-3.0",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/editor",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"description": "Core Editor that powers Plane",
"keywords": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/hooks",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"description": "React hooks that are shared across multiple apps internally",
"license": "AGPL-3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/i18n",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"description": "I18n shared across multiple apps internally",
"license": "AGPL-3.0",
@@ -395,7 +395,6 @@ export default {
time_tracking_description: "Zaznamenávejte čas strávený na pracovních položkách a projektech.",
work_management_description: "Spravujte svou práci a projekty snadno.",
documentation: "Dokumentace",
message_support: "Kontaktovat podporu",
contact_sales: "Kontaktovat prodej",
hyper_mode: "Hyper režim",
keyboard_shortcuts: "Klávesové zkratky",
@@ -406,7 +406,6 @@ export default {
time_tracking_description: "Erfassen Sie die auf Arbeitselemente und Projekte verwendete Zeit.",
work_management_description: "Verwalten Sie Ihre Arbeit und Projekte mühelos.",
documentation: "Dokumentation",
message_support: "Support kontaktieren",
contact_sales: "Vertrieb kontaktieren",
hyper_mode: "Hyper-Modus",
keyboard_shortcuts: "Tastaturkürzel",
@@ -229,7 +229,6 @@ export default {
time_tracking_description: "Log time spent on work items and projects.",
work_management_description: "Manage your work and projects with ease.",
documentation: "Documentation",
message_support: "Message support",
contact_sales: "Contact sales",
hyper_mode: "Hyper Mode",
keyboard_shortcuts: "Keyboard shortcuts",
@@ -2697,7 +2696,6 @@ export default {
open_plane_documentation: "Open Plane documentation",
join_forum: "Join our Forum",
report_bug: "Report a bug",
chat_with_us: "Chat with us",
},
page_placeholders: {
default: "Type a command or search",
@@ -406,7 +406,6 @@ export default {
time_tracking_description: "Registra el tiempo dedicado a elementos de trabajo y proyectos.",
work_management_description: "Gestiona tu trabajo y proyectos con facilidad.",
documentation: "Documentación",
message_support: "Mensaje al soporte",
contact_sales: "Contactar ventas",
hyper_mode: "Modo Hyper",
keyboard_shortcuts: "Atajos de teclado",
@@ -402,7 +402,6 @@ export default {
time_tracking_description: "Enregistrez le temps passé sur les éléments de travail et les projets.",
work_management_description: "Gérez votre travail et vos projets facilement.",
documentation: "Documentation",
message_support: "Contacter le support",
contact_sales: "Contacter les ventes",
hyper_mode: "Mode Hyper",
keyboard_shortcuts: "Raccourcis clavier",
@@ -398,7 +398,6 @@ export default {
time_tracking_description: "Catat waktu yang dihabiskan untuk item kerja dan proyek.",
work_management_description: "Kelola pekerjaan dan proyek Anda dengan mudah.",
documentation: "Dokumentasi",
message_support: "Pesan dukungan",
contact_sales: "Hubungi penjualan",
hyper_mode: "Mode Hyper",
keyboard_shortcuts: "Pintasan keyboard",
@@ -400,7 +400,6 @@ export default {
time_tracking_description: "Registra il tempo trascorso su elementi di lavoro e progetti.",
work_management_description: "Gestisci il tuo lavoro e i tuoi progetti con facilità.",
documentation: "Documentazione",
message_support: "Contatta il supporto",
contact_sales: "Contatta le vendite",
hyper_mode: "Modalità Hyper",
keyboard_shortcuts: "Scorciatoie da tastiera",
@@ -395,7 +395,6 @@ export default {
time_tracking_description: "作業項目やプロジェクトに費やした時間を記録します。",
work_management_description: "作業とプロジェクトを簡単に管理します。",
documentation: "ドキュメント",
message_support: "サポートにメッセージ",
contact_sales: "営業に問い合わせ",
hyper_mode: "Hyper Mode",
keyboard_shortcuts: "キーボードショートカット",
@@ -390,7 +390,6 @@ export default {
time_tracking_description: "작업 항목 및 프로젝트에 소요된 시간을 기록하세요.",
work_management_description: "작업 및 프로젝트를 쉽게 관리합니다.",
documentation: "문서",
message_support: "지원 메시지",
contact_sales: "영업 문의",
hyper_mode: "하이퍼 모드",
keyboard_shortcuts: "키보드 단축키",
@@ -393,7 +393,6 @@ export default {
time_tracking_description: "Rejestruj czas spędzony na elementach pracy i projektach.",
work_management_description: "Łatwo zarządzaj swoją pracą i projektami.",
documentation: "Dokumentacja",
message_support: "Skontaktuj się z pomocą",
contact_sales: "Skontaktuj się z działem sprzedaży",
hyper_mode: "Tryb Hyper",
keyboard_shortcuts: "Skróty klawiaturowe",
@@ -402,7 +402,6 @@ export default {
time_tracking_description: "Registre o tempo gasto em itens de trabalho e projetos.",
work_management_description: "Gerencie seu trabalho e projetos com facilidade.",
documentation: "Documentação",
message_support: "Suporte por mensagem",
contact_sales: "Contatar vendas",
hyper_mode: "Modo Hyper",
keyboard_shortcuts: "Atalhos do teclado",
@@ -399,7 +399,6 @@ export default {
time_tracking_description: "Înregistrează timpul petrecut pe activități și proiecte.",
work_management_description: "Gestionează-ți munca și proiectele cu ușurință.",
documentation: "Documentație",
message_support: "Trimite mesaj la suport",
contact_sales: "Contactează vânzările",
hyper_mode: "Mod Hyper",
keyboard_shortcuts: "Scurtături tastatură",
@@ -402,7 +402,6 @@ export default {
time_tracking_description: "Записывайте время, потраченное на рабочие элементы и проекты.",
work_management_description: "Управление рабочими элементами и проектами",
documentation: "Документация",
message_support: "Написать в поддержку",
contact_sales: "Связаться с отделом продаж",
hyper_mode: "Гиперрежим",
keyboard_shortcuts: "Горячие клавиши",
@@ -2869,7 +2868,6 @@ export default {
open_plane_documentation: "Открыть документацию Plane",
join_forum: "Присоединиться к Forum",
report_bug: "Сообщить об ошибке",
chat_with_us: "Написать нам",
},
page_placeholders: {
default: "Введите команду или поиск",
@@ -395,7 +395,6 @@ export default {
time_tracking_description: "Zaznamenajte čas strávený na pracovných položkách a projektoch.",
work_management_description: "Spravujte svoju prácu a projekty jednoducho.",
documentation: "Dokumentácia",
message_support: "Kontaktovať podporu",
contact_sales: "Kontaktovať predaj",
hyper_mode: "Hyper režim",
keyboard_shortcuts: "Klávesové skratky",
@@ -396,7 +396,6 @@ export default {
time_tracking_description: "İş öğeleri ve projelerde harcanan zamanı kaydedin.",
work_management_description: "İşlerinizi ve projelerinizi kolayca yönetin.",
documentation: "Dokümantasyon",
message_support: "Destekle iletişim",
contact_sales: "Satış Ekibiyle İletişim",
hyper_mode: "Hiper Mod",
keyboard_shortcuts: "Klavye Kısayolları",
@@ -402,7 +402,6 @@ export default {
time_tracking_description: "Фіксуйте час, витрачений на робочі одиниці та проєкти.",
work_management_description: "Зручно керуйте своєю роботою та проєктами.",
documentation: "Документація",
message_support: "Звернутися в підтримку",
contact_sales: "Зв’язатися з відділом продажів",
hyper_mode: "Гіпер-режим",
keyboard_shortcuts: "Гарячі клавіші",
@@ -2861,7 +2860,6 @@ export default {
open_plane_documentation: "Відкрити документацію Plane",
join_forum: "Приєднатися до Forum",
report_bug: "Повідомити про помилку",
chat_with_us: "Написати нам",
},
page_placeholders: {
default: "Введіть команду або виконайте пошук",
@@ -399,7 +399,6 @@ export default {
time_tracking_description: "Ghi lại thời gian dành cho các mục công việc và dự án.",
work_management_description: "Quản lý công việc và dự án của bạn một cách dễ dàng.",
documentation: "Tài liệu",
message_support: "Liên hệ hỗ trợ",
contact_sales: "Liên hệ bộ phận bán hàng",
hyper_mode: "Chế độ siêu tốc",
keyboard_shortcuts: "Phím tắt",
@@ -385,7 +385,6 @@ export default {
time_tracking_description: "记录在工作项和项目上花费的时间。",
work_management_description: "轻松管理您的工作和项目。",
documentation: "文档",
message_support: "联系支持",
contact_sales: "联系销售",
hyper_mode: "超级模式",
keyboard_shortcuts: "键盘快捷键",
@@ -384,7 +384,6 @@ export default {
time_tracking_description: "記錄在工作事項和專案上花費的時間。",
work_management_description: "輕鬆管理您的工作和專案。",
documentation: "文件",
message_support: "聯絡支援",
contact_sales: "聯絡業務",
hyper_mode: "極速模式",
keyboard_shortcuts: "鍵盤快速鍵",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/logger",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"description": "Logger shared across multiple apps internally",
"license": "AGPL-3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/propel",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"license": "AGPL-3.0",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/services",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"license": "AGPL-3.0",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/shared-state",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"description": "Shared state shared across multiple apps internally",
"license": "AGPL-3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/tailwind-config",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"description": "common tailwind configuration across monorepo",
"license": "AGPL-3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/types",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"license": "AGPL-3.0",
"type": "module",
-6
View File
@@ -65,9 +65,6 @@ export interface IInstanceConfig {
space_base_url: string | undefined;
admin_base_url: string | undefined;
is_self_managed: boolean;
// intercom
is_intercom_enabled: boolean;
intercom_app_id: string | undefined;
instance_changelog_url?: string;
}
@@ -83,14 +80,11 @@ export interface IInstanceAdmin {
user_detail: IUserLite;
}
export type TInstanceIntercomConfigurationKeys = "IS_INTERCOM_ENABLED" | "INTERCOM_APP_ID";
export type TInstanceConfigurationKeys =
| TInstanceAIConfigurationKeys
| TInstanceEmailConfigurationKeys
| TInstanceImageConfigurationKeys
| TInstanceAuthenticationKeys
| TInstanceIntercomConfigurationKeys
| TInstanceWorkspaceConfigurationKeys;
export interface IInstanceConfiguration {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/typescript-config",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"license": "AGPL-3.0",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/ui",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"description": "UI components shared across multiple apps internally",
"license": "AGPL-3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/utils",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"description": "Helper functions shared across multiple apps internally",
"license": "AGPL-3.0",
+276 -289
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -19,7 +19,7 @@ catalog:
"@types/node": 22.12.0
"@types/react-dom": 18.3.1
"@types/react": 18.3.11
axios: 1.13.5
axios: 1.15.2
express: 4.22.0
lodash-es: 4.18.0
lucide-react: 0.469.0
@@ -32,8 +32,8 @@ catalog:
swr: 2.2.4
tsdown: 0.16.0
typescript: 5.8.3
uuid: 13.0.0
vite: 7.3.1
uuid: 14.0.0
vite: 7.3.2
onlyBuiltDependencies:
- turbo