Compare commits
120
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b97a84671 | ||
|
|
6d3d9e6df7 | ||
|
|
d521eab22f | ||
|
|
00e070b509 | ||
|
|
4d17637edf | ||
|
|
bf45635a7b | ||
|
|
56d3a9e049 | ||
|
|
1f7eef5f81 | ||
|
|
bd2272a7da | ||
|
|
b9c6bb07bf | ||
|
|
345dfce25d | ||
|
|
116c8118ab | ||
|
|
bfce5c860a | ||
|
|
2610e88274 | ||
|
|
4466ec046c | ||
|
|
c3e7cfd16b | ||
|
|
9ffc30f7b1 | ||
|
|
b60f12a88e | ||
|
|
76a0b38dd1 | ||
|
|
3235186374 | ||
|
|
8ee665f491 | ||
|
|
85f23b450d | ||
|
|
8bf059535a | ||
|
|
4cfea87108 | ||
|
|
4fe2ef706b | ||
|
|
8d354b3eb2 | ||
|
|
ec541c2557 | ||
|
|
11cd8d11e4 | ||
|
|
0f7bfdde91 | ||
|
|
ac835bf287 | ||
|
|
db18c3555c | ||
|
|
b696ae91ed | ||
|
|
61e91bd09c | ||
|
|
20d773042b | ||
|
|
30b175108b | ||
|
|
6d116beea3 | ||
|
|
b0db4fcf10 | ||
|
|
7e03264758 | ||
|
|
1c8ac3d247 | ||
|
|
3b8bb1effc | ||
|
|
5a63e6dad2 | ||
|
|
45688bdc72 | ||
|
|
43b7a6ad0a | ||
|
|
498613284e | ||
|
|
9ab3143a73 | ||
|
|
56cd0fc445 | ||
|
|
260d9a053d | ||
|
|
34bdc2ad76 | ||
|
|
99bc4262c5 | ||
|
|
49127ebeea | ||
|
|
a8a6536379 | ||
|
|
11b83cf4f2 | ||
|
|
fbd48c33f5 | ||
|
|
37ce8a9fe6 | ||
|
|
291101a8e5 | ||
|
|
0cb4976e38 | ||
|
|
20d139cc9e | ||
|
|
ff181e566f | ||
|
|
a696b6039c | ||
|
|
af1dcd335e | ||
|
|
a1500c2206 | ||
|
|
88f194ca8e | ||
|
|
b68d2ca921 | ||
|
|
78c6aede52 | ||
|
|
c6c46f9aab | ||
|
|
7d7e37439d | ||
|
|
d258080ee7 | ||
|
|
c40c7804e7 | ||
|
|
36c735bede | ||
|
|
cab5d20217 | ||
|
|
9965fc75a6 | ||
|
|
cb1e93f83d | ||
|
|
2e673026d4 | ||
|
|
49a40031a4 | ||
|
|
54e4a0f6dd | ||
|
|
473535fcfa | ||
|
|
59ace4f411 | ||
|
|
b99ddc24e7 | ||
|
|
91f0228b5f | ||
|
|
4b74751e4e | ||
|
|
1c28bd5887 | ||
|
|
292264ba96 | ||
|
|
28d368d502 | ||
|
|
652a6cc885 | ||
|
|
26b48bfcf0 | ||
|
|
569b2fba83 | ||
|
|
779a96acb8 | ||
|
|
2d31b562d8 | ||
|
|
50b4aca726 | ||
|
|
d960d7ce88 | ||
|
|
fd5ba6c7b8 | ||
|
|
f42eeec2c0 | ||
|
|
e679dc3d12 | ||
|
|
f2edf637de | ||
|
|
7437deaa86 | ||
|
|
64b95daff4 | ||
|
|
34181fba80 | ||
|
|
0358e9b965 | ||
|
|
ee471c772a | ||
|
|
e2c0d0f23c | ||
|
|
3ecebc02ae | ||
|
|
16d531cc7a | ||
|
|
ab283c7c78 | ||
|
|
7c1bbf4a6f | ||
|
|
4293892178 | ||
|
|
ebd517bb7d | ||
|
|
4042af9f32 | ||
|
|
258d24bf06 | ||
|
|
489a6e1e94 | ||
|
|
4f349807be | ||
|
|
7a43137620 | ||
|
|
e144ce8cf2 | ||
|
|
ba7303b7af | ||
|
|
e0912ccefc | ||
|
|
aef465415b | ||
|
|
3b3bd3e54e | ||
|
|
f2fabff10a | ||
|
|
ddeabeeeb1 | ||
|
|
0e6fbaee3a | ||
|
|
cfe710d492 |
@@ -17,8 +17,6 @@ jobs:
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Get PR Branch version
|
||||
run: echo "PR_VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV
|
||||
|
||||
@@ -3,11 +3,9 @@ name: "CodeQL"
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: ["preview", "master"]
|
||||
branches: ["preview", "canary", "master"]
|
||||
pull_request:
|
||||
branches: ["develop", "preview", "master"]
|
||||
schedule:
|
||||
- cron: "53 19 * * 5"
|
||||
branches: ["preview", "canary", "master"]
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
|
||||
@@ -36,8 +36,6 @@ jobs:
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
- name: Enable Corepack and pnpm
|
||||
run: corepack enable pnpm
|
||||
|
||||
@@ -14,7 +14,7 @@ strict-peer-dependencies=false
|
||||
# Turbo occasionally performs postinstall tasks for optimal performance
|
||||
# moved to pnpm-workspace.yaml: onlyBuiltDependencies (e.g., allow turbo)
|
||||
|
||||
public-hoist-pattern[]=eslint
|
||||
public-hoist-pattern[]=*eslint*
|
||||
public-hoist-pattern[]=prettier
|
||||
public-hoist-pattern[]=typescript
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
.next/*
|
||||
out/*
|
||||
public/*
|
||||
dist/*
|
||||
node_modules/*
|
||||
.turbo/*
|
||||
.env*
|
||||
.env
|
||||
.env.local
|
||||
.env.development
|
||||
.env.production
|
||||
.env.test
|
||||
@@ -1,5 +1,4 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["@plane/eslint-config/next.js"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useInstance } from "@/hooks/store";
|
||||
// components
|
||||
import { InstanceEmailForm } from "./email-config-form";
|
||||
|
||||
const InstanceEmailPage = observer(() => {
|
||||
const InstanceEmailPage: React.FC = observer(() => {
|
||||
// store
|
||||
const { fetchInstanceConfigurations, formattedConfig, disableEmail } = useInstance();
|
||||
|
||||
@@ -29,7 +29,7 @@ const InstanceEmailPage = observer(() => {
|
||||
message: "Email feature has been disabled",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
});
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
setToast({
|
||||
title: "Error disabling email",
|
||||
message: "Failed to disable email feature. Please try again.",
|
||||
|
||||
@@ -7,7 +7,8 @@ import { ExternalLink, FileText, HelpCircle, MoveLeft } from "lucide-react";
|
||||
import { Transition } from "@headlessui/react";
|
||||
// plane internal packages
|
||||
import { WEB_BASE_URL } from "@plane/constants";
|
||||
import { DiscordIcon, GithubIcon, Tooltip } from "@plane/ui";
|
||||
import { DiscordIcon, GithubIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useTheme } from "@/hooks/store";
|
||||
|
||||
@@ -5,7 +5,8 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react";
|
||||
// plane internal packages
|
||||
import { Tooltip, WorkspaceIcon } from "@plane/ui";
|
||||
import { WorkspaceIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useTheme } from "@/hooks/store";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { PlaneLockup } from "@plane/ui";
|
||||
import { PlaneLockup } from "@plane/propel/icons";
|
||||
|
||||
export const AuthHeader = () => (
|
||||
<div className="flex items-center justify-between gap-6 w-full flex-shrink-0 sticky top-0">
|
||||
|
||||
@@ -25,9 +25,8 @@ export const EmailCodesConfiguration: React.FC<Props> = observer((props) => {
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableMagicLogin))}
|
||||
onChange={() => {
|
||||
Boolean(parseInt(enableMagicLogin)) === true
|
||||
? updateConfig("ENABLE_MAGIC_LINK_LOGIN", "0")
|
||||
: updateConfig("ENABLE_MAGIC_LINK_LOGIN", "1");
|
||||
const newEnableMagicLogin = Boolean(parseInt(enableMagicLogin)) === true ? "0" : "1";
|
||||
updateConfig("ENABLE_MAGIC_LINK_LOGIN", newEnableMagicLogin);
|
||||
}}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
|
||||
@@ -35,9 +35,8 @@ export const GithubConfiguration: React.FC<Props> = observer((props) => {
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableGithubConfig))}
|
||||
onChange={() => {
|
||||
Boolean(parseInt(enableGithubConfig)) === true
|
||||
? updateConfig("IS_GITHUB_ENABLED", "0")
|
||||
: updateConfig("IS_GITHUB_ENABLED", "1");
|
||||
const newEnableGithubConfig = Boolean(parseInt(enableGithubConfig)) === true ? "0" : "1";
|
||||
updateConfig("IS_GITHUB_ENABLED", newEnableGithubConfig);
|
||||
}}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
|
||||
@@ -35,9 +35,8 @@ export const GitlabConfiguration: React.FC<Props> = observer((props) => {
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableGitlabConfig))}
|
||||
onChange={() => {
|
||||
Boolean(parseInt(enableGitlabConfig)) === true
|
||||
? updateConfig("IS_GITLAB_ENABLED", "0")
|
||||
: updateConfig("IS_GITLAB_ENABLED", "1");
|
||||
const newEnableGitlabConfig = Boolean(parseInt(enableGitlabConfig)) === true ? "0" : "1";
|
||||
updateConfig("IS_GITLAB_ENABLED", newEnableGitlabConfig);
|
||||
}}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
|
||||
@@ -35,9 +35,8 @@ export const GoogleConfiguration: React.FC<Props> = observer((props) => {
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableGoogleConfig))}
|
||||
onChange={() => {
|
||||
Boolean(parseInt(enableGoogleConfig)) === true
|
||||
? updateConfig("IS_GOOGLE_ENABLED", "0")
|
||||
: updateConfig("IS_GOOGLE_ENABLED", "1");
|
||||
const newEnableGoogleConfig = Boolean(parseInt(enableGoogleConfig)) === true ? "0" : "1";
|
||||
updateConfig("IS_GOOGLE_ENABLED", newEnableGoogleConfig);
|
||||
}}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
|
||||
@@ -25,9 +25,8 @@ export const PasswordLoginConfiguration: React.FC<Props> = observer((props) => {
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableEmailPassword))}
|
||||
onChange={() => {
|
||||
Boolean(parseInt(enableEmailPassword)) === true
|
||||
? updateConfig("ENABLE_EMAIL_PASSWORD", "0")
|
||||
: updateConfig("ENABLE_EMAIL_PASSWORD", "1");
|
||||
const newEnableEmailPassword = Boolean(parseInt(enableEmailPassword)) === true ? "0" : "1";
|
||||
updateConfig("ENABLE_EMAIL_PASSWORD", newEnableEmailPassword);
|
||||
}}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
|
||||
type Props = {
|
||||
label?: string;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { observer } from "mobx-react";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
// plane internal packages
|
||||
import { WEB_BASE_URL } from "@plane/constants";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { getFileURL } from "@plane/utils";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store";
|
||||
|
||||
@@ -209,7 +209,7 @@ export class InstanceStore implements IInstanceStore {
|
||||
});
|
||||
});
|
||||
await this.instanceService.disableEmail();
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
console.error("Error disabling the email");
|
||||
this.instanceConfigurations = instanceConfigurations;
|
||||
}
|
||||
|
||||
+16
-16
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"description": "Admin UI for Plane",
|
||||
"version": "0.28.0",
|
||||
"version": "1.0.0",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -26,30 +26,30 @@
|
||||
"@plane/ui": "workspace:*",
|
||||
"@plane/utils": "workspace:*",
|
||||
"autoprefixer": "10.4.14",
|
||||
"axios": "1.11.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.469.0",
|
||||
"mobx": "^6.12.0",
|
||||
"mobx-react": "^9.1.1",
|
||||
"next": "14.2.30",
|
||||
"axios": "catalog:",
|
||||
"lodash": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"mobx": "catalog:",
|
||||
"mobx-react": "catalog:",
|
||||
"next": "catalog:",
|
||||
"next-themes": "^0.2.1",
|
||||
"postcss": "^8.4.49",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-hook-form": "7.51.5",
|
||||
"sharp": "^0.33.5",
|
||||
"swr": "^2.2.4",
|
||||
"uuid": "^9.0.1"
|
||||
"sharp": "catalog:",
|
||||
"swr": "catalog:",
|
||||
"uuid": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@plane/tailwind-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@types/lodash": "^4.17.6",
|
||||
"@types/lodash": "catalog:",
|
||||
"@types/node": "18.16.1",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"typescript": "5.8.3"
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "plane-api",
|
||||
"version": "0.28.0",
|
||||
"version": "1.0.0",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"description": "API server powering Plane's backend"
|
||||
|
||||
@@ -91,6 +91,7 @@ class BaseSerializer(serializers.ModelSerializer):
|
||||
"project_lead": UserLiteSerializer,
|
||||
"state": StateLiteSerializer,
|
||||
"created_by": UserLiteSerializer,
|
||||
"updated_by": UserLiteSerializer,
|
||||
"issue": IssueSerializer,
|
||||
"actor": UserLiteSerializer,
|
||||
"owned_by": UserLiteSerializer,
|
||||
|
||||
@@ -39,13 +39,31 @@ def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None):
|
||||
).exists():
|
||||
return view_func(instance, request, *args, **kwargs)
|
||||
else:
|
||||
if ProjectMember.objects.filter(
|
||||
is_user_has_allowed_role = ProjectMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=kwargs["slug"],
|
||||
project_id=kwargs["project_id"],
|
||||
role__in=allowed_role_values,
|
||||
is_active=True,
|
||||
).exists():
|
||||
).exists()
|
||||
|
||||
# Return if the user has the allowed role else if they are workspace admin and part of the project regardless of the role
|
||||
if is_user_has_allowed_role:
|
||||
return view_func(instance, request, *args, **kwargs)
|
||||
elif (
|
||||
ProjectMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=kwargs["slug"],
|
||||
project_id=kwargs["project_id"],
|
||||
is_active=True,
|
||||
).exists()
|
||||
and WorkspaceMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=kwargs["slug"],
|
||||
role=ROLE.ADMIN.value,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
return view_func(instance, request, *args, **kwargs)
|
||||
|
||||
# Return permission denied if no conditions are met
|
||||
|
||||
@@ -3,11 +3,7 @@ from rest_framework.permissions import SAFE_METHODS, BasePermission
|
||||
|
||||
# Module import
|
||||
from plane.db.models import ProjectMember, WorkspaceMember
|
||||
|
||||
# Permission Mappings
|
||||
Admin = 20
|
||||
Member = 15
|
||||
Guest = 5
|
||||
from plane.db.models.project import ROLE
|
||||
|
||||
|
||||
class ProjectBasePermission(BasePermission):
|
||||
@@ -26,18 +22,31 @@ class ProjectBasePermission(BasePermission):
|
||||
return WorkspaceMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role__in=[Admin, Member],
|
||||
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
## Only Project Admins can update project attributes
|
||||
return ProjectMember.objects.filter(
|
||||
project_member_qs = ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role=Admin,
|
||||
project_id=view.project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
)
|
||||
|
||||
## Only project admins or workspace admin who is part of the project can access
|
||||
|
||||
if project_member_qs.filter(role=ROLE.ADMIN.value).exists():
|
||||
return True
|
||||
else:
|
||||
return (
|
||||
project_member_qs.exists()
|
||||
and WorkspaceMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=view.workspace_slug,
|
||||
role=ROLE.ADMIN.value,
|
||||
is_active=True,
|
||||
).exists()
|
||||
)
|
||||
|
||||
|
||||
class ProjectMemberPermission(BasePermission):
|
||||
@@ -55,7 +64,7 @@ class ProjectMemberPermission(BasePermission):
|
||||
return WorkspaceMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role__in=[Admin, Member],
|
||||
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
@@ -63,7 +72,7 @@ class ProjectMemberPermission(BasePermission):
|
||||
return ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role__in=[Admin, Member],
|
||||
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
|
||||
project_id=view.project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
@@ -97,7 +106,7 @@ class ProjectEntityPermission(BasePermission):
|
||||
return ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role__in=[Admin, Member],
|
||||
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
|
||||
project_id=view.project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
@@ -667,16 +667,33 @@ class IssueReactionSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class IssueReactionLiteSerializer(DynamicBaseSerializer):
|
||||
display_name = serializers.CharField(source="actor.display_name", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = IssueReaction
|
||||
fields = ["id", "actor", "issue", "reaction"]
|
||||
fields = ["id", "actor", "issue", "reaction", "display_name"]
|
||||
|
||||
|
||||
class CommentReactionSerializer(BaseSerializer):
|
||||
display_name = serializers.CharField(source="actor.display_name", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = CommentReaction
|
||||
fields = "__all__"
|
||||
read_only_fields = ["workspace", "project", "comment", "actor", "deleted_at"]
|
||||
fields = [
|
||||
"id",
|
||||
"actor",
|
||||
"comment",
|
||||
"reaction",
|
||||
"display_name",
|
||||
"deleted_at",
|
||||
"workspace",
|
||||
"project",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
]
|
||||
read_only_fields = ["workspace", "project", "comment", "actor", "deleted_at", "created_by", "updated_by"]
|
||||
|
||||
|
||||
class IssueVoteSerializer(BaseSerializer):
|
||||
|
||||
@@ -15,7 +15,6 @@ from plane.db.models import (
|
||||
)
|
||||
from plane.utils.content_validator import (
|
||||
validate_html_content,
|
||||
validate_binary_data,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -441,7 +441,11 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
|
||||
# Get the presigned URL
|
||||
storage = S3Storage(request=request)
|
||||
# Generate a presigned URL to share an S3 object
|
||||
signed_url = storage.generate_presigned_url(object_name=asset.asset.name)
|
||||
signed_url = storage.generate_presigned_url(
|
||||
object_name=asset.asset.name,
|
||||
disposition="attachment",
|
||||
filename=asset.attributes.get("name"),
|
||||
)
|
||||
# Redirect to the signed URL
|
||||
return HttpResponseRedirect(signed_url)
|
||||
|
||||
@@ -641,7 +645,11 @@ class ProjectAssetEndpoint(BaseAPIView):
|
||||
# Get the presigned URL
|
||||
storage = S3Storage(request=request)
|
||||
# Generate a presigned URL to share an S3 object
|
||||
signed_url = storage.generate_presigned_url(object_name=asset.asset.name)
|
||||
signed_url = storage.generate_presigned_url(
|
||||
object_name=asset.asset.name,
|
||||
disposition="attachment",
|
||||
filename=asset.attributes.get("name"),
|
||||
)
|
||||
# Redirect to the signed URL
|
||||
return HttpResponseRedirect(signed_url)
|
||||
|
||||
|
||||
@@ -198,6 +198,7 @@ class PageViewSet(BaseViewSet):
|
||||
def retrieve(self, request, slug, project_id, pk=None):
|
||||
page = self.get_queryset().filter(pk=pk).first()
|
||||
project = Project.objects.get(pk=project_id)
|
||||
track_visit = request.query_params.get("track_visit", "true").lower() == "true"
|
||||
|
||||
"""
|
||||
if the role is guest and guest_view_all_features is false and owned by is not
|
||||
@@ -230,13 +231,14 @@ class PageViewSet(BaseViewSet):
|
||||
).values_list("entity_identifier", flat=True)
|
||||
data = PageDetailSerializer(page).data
|
||||
data["issue_ids"] = issue_ids
|
||||
recent_visited_task.delay(
|
||||
slug=slug,
|
||||
entity_name="page",
|
||||
entity_identifier=pk,
|
||||
user_id=request.user.id,
|
||||
project_id=project_id,
|
||||
)
|
||||
if track_visit:
|
||||
recent_visited_task.delay(
|
||||
slug=slug,
|
||||
entity_name="page",
|
||||
entity_identifier=pk,
|
||||
user_id=request.user.id,
|
||||
project_id=project_id,
|
||||
)
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
|
||||
|
||||
@@ -5,13 +5,12 @@ from django.utils import timezone
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import serializers, status
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny
|
||||
|
||||
# Module imports
|
||||
@@ -106,7 +105,10 @@ class ProjectViewSet(BaseViewSet):
|
||||
fields = [field for field in request.GET.get("fields", "").split(",") if field]
|
||||
projects = self.get_queryset().order_by("sort_order", "name")
|
||||
if WorkspaceMember.objects.filter(
|
||||
member=request.user, workspace__slug=slug, is_active=True, role=5
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
role=ROLE.GUEST.value,
|
||||
).exists():
|
||||
projects = projects.filter(
|
||||
project_projectmember__member=self.request.user,
|
||||
@@ -114,7 +116,10 @@ class ProjectViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
if WorkspaceMember.objects.filter(
|
||||
member=request.user, workspace__slug=slug, is_active=True, role=15
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
role=ROLE.MEMBER.value,
|
||||
).exists():
|
||||
projects = projects.filter(
|
||||
Q(
|
||||
@@ -189,7 +194,10 @@ class ProjectViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
if WorkspaceMember.objects.filter(
|
||||
member=request.user, workspace__slug=slug, is_active=True, role=5
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
role=ROLE.GUEST.value,
|
||||
).exists():
|
||||
projects = projects.filter(
|
||||
project_projectmember__member=self.request.user,
|
||||
@@ -197,7 +205,10 @@ class ProjectViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
if WorkspaceMember.objects.filter(
|
||||
member=request.user, workspace__slug=slug, is_active=True, role=15
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
role=ROLE.MEMBER.value,
|
||||
).exists():
|
||||
projects = projects.filter(
|
||||
Q(
|
||||
@@ -250,7 +261,9 @@ class ProjectViewSet(BaseViewSet):
|
||||
|
||||
# Add the user as Administrator to the project
|
||||
_ = ProjectMember.objects.create(
|
||||
project_id=serializer.data["id"], member=request.user, role=20
|
||||
project_id=serializer.data["id"],
|
||||
member=request.user,
|
||||
role=ROLE.ADMIN.value,
|
||||
)
|
||||
# Also create the issue property for the user
|
||||
_ = IssueUserProperty.objects.create(
|
||||
@@ -263,7 +276,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
ProjectMember.objects.create(
|
||||
project_id=serializer.data["id"],
|
||||
member_id=serializer.data["project_lead"],
|
||||
role=20,
|
||||
role=ROLE.ADMIN.value,
|
||||
)
|
||||
# Also create the issue property for the user
|
||||
IssueUserProperty.objects.create(
|
||||
@@ -341,13 +354,23 @@ class ProjectViewSet(BaseViewSet):
|
||||
|
||||
def partial_update(self, request, slug, pk=None):
|
||||
# try:
|
||||
if not ProjectMember.objects.filter(
|
||||
is_workspace_admin = WorkspaceMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
role=ROLE.ADMIN.value,
|
||||
).exists()
|
||||
|
||||
is_project_admin = ProjectMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
project_id=pk,
|
||||
role=20,
|
||||
role=ROLE.ADMIN.value,
|
||||
is_active=True,
|
||||
).exists():
|
||||
).exists()
|
||||
|
||||
# Return error for if the user is neither workspace admin nor project admin
|
||||
if not is_project_admin and not is_workspace_admin:
|
||||
return Response(
|
||||
{"error": "You don't have the required permissions."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
@@ -402,13 +425,16 @@ class ProjectViewSet(BaseViewSet):
|
||||
def destroy(self, request, slug, pk):
|
||||
if (
|
||||
WorkspaceMember.objects.filter(
|
||||
member=request.user, workspace__slug=slug, is_active=True, role=20
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
role=ROLE.ADMIN.value,
|
||||
).exists()
|
||||
or ProjectMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
project_id=pk,
|
||||
role=20,
|
||||
role=ROLE.ADMIN.value,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
|
||||
@@ -172,12 +172,14 @@ class WorkspaceDraftIssueViewSet(BaseViewSet):
|
||||
{"error": "Issue not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
project_id = request.data.get("project_id", issue.project_id)
|
||||
|
||||
serializer = DraftIssueCreateSerializer(
|
||||
issue,
|
||||
data=request.data,
|
||||
partial=True,
|
||||
context={
|
||||
"project_id": request.data.get("project_id", None),
|
||||
"project_id": project_id,
|
||||
"cycle_id": request.data.get("cycle_id", "not_provided"),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -7,7 +7,6 @@ from plane.app.serializers import StateSerializer
|
||||
from plane.app.views.base import BaseAPIView
|
||||
from plane.db.models import State
|
||||
from plane.app.permissions import WorkspaceEntityPermission
|
||||
from plane.utils.cache import cache_response
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
@@ -15,7 +14,6 @@ class WorkspaceStatesEndpoint(BaseAPIView):
|
||||
permission_classes = [WorkspaceEntityPermission]
|
||||
use_read_replica = True
|
||||
|
||||
@cache_response(60 * 60 * 2)
|
||||
def get(self, request, slug):
|
||||
states = State.objects.filter(
|
||||
workspace__slug=slug,
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# Python imports
|
||||
from urllib.parse import urlencode, urljoin
|
||||
|
||||
# Django imports
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_email
|
||||
@@ -19,7 +16,7 @@ from plane.authentication.adapter.error import (
|
||||
AuthenticationException,
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
from plane.utils.path_validator import get_safe_redirect_url
|
||||
|
||||
|
||||
class SignInAuthEndpoint(View):
|
||||
@@ -34,11 +31,11 @@ class SignInAuthEndpoint(View):
|
||||
error_message="INSTANCE_NOT_CONFIGURED",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
# Base URL join
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -58,10 +55,10 @@ class SignInAuthEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
# Next path
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -76,10 +73,10 @@ class SignInAuthEndpoint(View):
|
||||
payload={"email": str(email)},
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -92,10 +89,10 @@ class SignInAuthEndpoint(View):
|
||||
payload={"email": str(email)},
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -112,19 +109,23 @@ class SignInAuthEndpoint(View):
|
||||
user_login(request=request, user=user, is_app=True)
|
||||
# Get the redirection path
|
||||
if next_path:
|
||||
path = str(validate_next_path(next_path))
|
||||
path = next_path
|
||||
else:
|
||||
path = get_redirection_path(user=user)
|
||||
|
||||
# redirect to referer path
|
||||
url = urljoin(base_host(request=request, is_app=True), path)
|
||||
# Get the safe redirect URL
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=path,
|
||||
params={},
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -141,10 +142,10 @@ class SignUpAuthEndpoint(View):
|
||||
error_message="INSTANCE_NOT_CONFIGURED",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -161,10 +162,10 @@ class SignUpAuthEndpoint(View):
|
||||
payload={"email": str(email)},
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
# Validate the email
|
||||
@@ -179,10 +180,10 @@ class SignUpAuthEndpoint(View):
|
||||
payload={"email": str(email)},
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -197,10 +198,10 @@ class SignUpAuthEndpoint(View):
|
||||
payload={"email": str(email)},
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -217,17 +218,21 @@ class SignUpAuthEndpoint(View):
|
||||
user_login(request=request, user=user, is_app=True)
|
||||
# Get the redirection path
|
||||
if next_path:
|
||||
path = str(validate_next_path(next_path))
|
||||
path = next_path
|
||||
else:
|
||||
path = get_redirection_path(user=user)
|
||||
# redirect to referer path
|
||||
url = urljoin(base_host(request=request, is_app=True), path)
|
||||
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=path,
|
||||
params={},
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Python imports
|
||||
import uuid
|
||||
from urllib.parse import urlencode, urljoin
|
||||
|
||||
# Django import
|
||||
from django.http import HttpResponseRedirect
|
||||
@@ -16,8 +16,7 @@ from plane.authentication.adapter.error import (
|
||||
AuthenticationException,
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
|
||||
from plane.utils.path_validator import get_safe_redirect_url
|
||||
|
||||
class GitHubOauthInitiateEndpoint(View):
|
||||
def get(self, request):
|
||||
@@ -35,10 +34,10 @@ class GitHubOauthInitiateEndpoint(View):
|
||||
error_message="INSTANCE_NOT_CONFIGURED",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
try:
|
||||
@@ -49,10 +48,10 @@ class GitHubOauthInitiateEndpoint(View):
|
||||
return HttpResponseRedirect(auth_url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -61,7 +60,6 @@ class GitHubCallbackEndpoint(View):
|
||||
def get(self, request):
|
||||
code = request.GET.get("code")
|
||||
state = request.GET.get("state")
|
||||
base_host = request.session.get("host")
|
||||
next_path = request.session.get("next_path")
|
||||
|
||||
if state != request.session.get("state", ""):
|
||||
@@ -70,9 +68,11 @@ class GitHubCallbackEndpoint(View):
|
||||
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(base_host, "?" + urlencode(params))
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
if not code:
|
||||
@@ -81,9 +81,11 @@ class GitHubCallbackEndpoint(View):
|
||||
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(base_host, "?" + urlencode(params))
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
try:
|
||||
@@ -93,17 +95,23 @@ class GitHubCallbackEndpoint(View):
|
||||
user = provider.authenticate()
|
||||
# Login the user and record his device info
|
||||
user_login(request=request, user=user, is_app=True)
|
||||
# Get the redirection path
|
||||
if next_path:
|
||||
path = str(validate_next_path(next_path))
|
||||
path = next_path
|
||||
else:
|
||||
path = get_redirection_path(user=user)
|
||||
# redirect to referer path
|
||||
url = urljoin(base_host, path)
|
||||
|
||||
# Get the safe redirect URL
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=path,
|
||||
params={}
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(base_host, "?" + urlencode(params))
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Python imports
|
||||
import uuid
|
||||
from urllib.parse import urlencode, urljoin
|
||||
|
||||
# Django import
|
||||
from django.http import HttpResponseRedirect
|
||||
@@ -16,7 +16,7 @@ from plane.authentication.adapter.error import (
|
||||
AuthenticationException,
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
from plane.utils.path_validator import get_safe_redirect_url
|
||||
|
||||
|
||||
class GitLabOauthInitiateEndpoint(View):
|
||||
@@ -25,7 +25,7 @@ class GitLabOauthInitiateEndpoint(View):
|
||||
request.session["host"] = base_host(request=request, is_app=True)
|
||||
next_path = request.GET.get("next_path")
|
||||
if next_path:
|
||||
request.session["next_path"] = str(validate_next_path(next_path))
|
||||
request.session["next_path"] = str(next_path)
|
||||
|
||||
# Check instance configuration
|
||||
instance = Instance.objects.first()
|
||||
@@ -35,10 +35,10 @@ class GitLabOauthInitiateEndpoint(View):
|
||||
error_message="INSTANCE_NOT_CONFIGURED",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
try:
|
||||
@@ -49,10 +49,10 @@ class GitLabOauthInitiateEndpoint(View):
|
||||
return HttpResponseRedirect(auth_url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -61,7 +61,6 @@ class GitLabCallbackEndpoint(View):
|
||||
def get(self, request):
|
||||
code = request.GET.get("code")
|
||||
state = request.GET.get("state")
|
||||
base_host = request.session.get("host")
|
||||
next_path = request.session.get("next_path")
|
||||
|
||||
if state != request.session.get("state", ""):
|
||||
@@ -70,9 +69,11 @@ class GitLabCallbackEndpoint(View):
|
||||
error_message="GITLAB_OAUTH_PROVIDER_ERROR",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(next_path)
|
||||
url = urljoin(base_host, "?" + urlencode(params))
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
if not code:
|
||||
@@ -81,9 +82,11 @@ class GitLabCallbackEndpoint(View):
|
||||
error_message="GITLAB_OAUTH_PROVIDER_ERROR",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(base_host, "?" + urlencode(params))
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
try:
|
||||
@@ -94,16 +97,23 @@ class GitLabCallbackEndpoint(View):
|
||||
# Login the user and record his device info
|
||||
user_login(request=request, user=user, is_app=True)
|
||||
# Get the redirection path
|
||||
|
||||
if next_path:
|
||||
path = str(validate_next_path(next_path))
|
||||
path = next_path
|
||||
else:
|
||||
path = get_redirection_path(user=user)
|
||||
# redirect to referer path
|
||||
url = urljoin(base_host, path)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=path,
|
||||
params={}
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(base_host, "?" + urlencode(params))
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# Python imports
|
||||
import uuid
|
||||
from urllib.parse import urlencode, urljoin
|
||||
|
||||
# Django import
|
||||
from django.http import HttpResponseRedirect
|
||||
@@ -18,7 +17,7 @@ from plane.authentication.adapter.error import (
|
||||
AuthenticationException,
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
from plane.utils.path_validator import get_safe_redirect_url
|
||||
|
||||
|
||||
class GoogleOauthInitiateEndpoint(View):
|
||||
@@ -36,10 +35,10 @@ class GoogleOauthInitiateEndpoint(View):
|
||||
error_message="INSTANCE_NOT_CONFIGURED",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -51,10 +50,10 @@ class GoogleOauthInitiateEndpoint(View):
|
||||
return HttpResponseRedirect(auth_url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -63,7 +62,6 @@ class GoogleCallbackEndpoint(View):
|
||||
def get(self, request):
|
||||
code = request.GET.get("code")
|
||||
state = request.GET.get("state")
|
||||
base_host = request.session.get("host")
|
||||
next_path = request.session.get("next_path")
|
||||
|
||||
if state != request.session.get("state", ""):
|
||||
@@ -72,9 +70,11 @@ class GoogleCallbackEndpoint(View):
|
||||
error_message="GOOGLE_OAUTH_PROVIDER_ERROR",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(base_host, "?" + urlencode(params))
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
if not code:
|
||||
exc = AuthenticationException(
|
||||
@@ -82,9 +82,11 @@ class GoogleCallbackEndpoint(View):
|
||||
error_message="GOOGLE_OAUTH_PROVIDER_ERROR",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(base_host, "?" + urlencode(params))
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
try:
|
||||
provider = GoogleOAuthProvider(
|
||||
@@ -94,15 +96,21 @@ class GoogleCallbackEndpoint(View):
|
||||
# Login the user and record his device info
|
||||
user_login(request=request, user=user, is_app=True)
|
||||
# Get the redirection path
|
||||
path = get_redirection_path(user=user)
|
||||
# redirect to referer path
|
||||
url = urljoin(
|
||||
base_host, str(validate_next_path(next_path)) if next_path else path
|
||||
if next_path:
|
||||
path = next_path
|
||||
else:
|
||||
path = get_redirection_path(user=user)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=path,
|
||||
params={}
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(base_host, "?" + urlencode(params))
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# Python imports
|
||||
from urllib.parse import urlencode, urljoin
|
||||
|
||||
# Django imports
|
||||
from django.core.validators import validate_email
|
||||
from django.http import HttpResponseRedirect
|
||||
@@ -26,7 +23,7 @@ from plane.authentication.adapter.error import (
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
)
|
||||
from plane.authentication.rate_limit import AuthenticationThrottle
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
from plane.utils.path_validator import get_safe_redirect_url
|
||||
|
||||
|
||||
class MagicGenerateEndpoint(APIView):
|
||||
@@ -72,10 +69,10 @@ class MagicSignInEndpoint(View):
|
||||
error_message="MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -88,10 +85,10 @@ class MagicSignInEndpoint(View):
|
||||
error_message="USER_DOES_NOT_EXIST",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -107,7 +104,8 @@ class MagicSignInEndpoint(View):
|
||||
# Login the user and record his device info
|
||||
user_login(request=request, user=user, is_app=True)
|
||||
if user.is_password_autoset and profile.is_onboarded:
|
||||
path = "accounts/set-password"
|
||||
# Redirect to the home page
|
||||
path = "/"
|
||||
else:
|
||||
# Get the redirection path
|
||||
path = (
|
||||
@@ -116,15 +114,19 @@ class MagicSignInEndpoint(View):
|
||||
else str(get_redirection_path(user=user))
|
||||
)
|
||||
# redirect to referer path
|
||||
url = urljoin(base_host(request=request, is_app=True), path)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=path,
|
||||
params={},
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -144,10 +146,10 @@ class MagicSignUpEndpoint(View):
|
||||
error_message="MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
# Existing user
|
||||
@@ -158,10 +160,10 @@ class MagicSignUpEndpoint(View):
|
||||
error_message="USER_ALREADY_EXIST",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -177,18 +179,22 @@ class MagicSignUpEndpoint(View):
|
||||
user_login(request=request, user=user, is_app=True)
|
||||
# Get the redirection path
|
||||
if next_path:
|
||||
path = str(validate_next_path(next_path))
|
||||
path = next_path
|
||||
else:
|
||||
path = get_redirection_path(user=user)
|
||||
# redirect to referer path
|
||||
url = urljoin(base_host(request=request, is_app=True), path)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=path,
|
||||
params={},
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
# Python imports
|
||||
from urllib.parse import urlencode
|
||||
|
||||
# Django imports
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_email
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.views import View
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
|
||||
# Module imports
|
||||
from plane.authentication.provider.credentials.email import EmailProvider
|
||||
@@ -17,7 +15,7 @@ from plane.authentication.adapter.error import (
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
AuthenticationException,
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path
|
||||
|
||||
|
||||
class SignInAuthSpaceEndpoint(View):
|
||||
@@ -32,9 +30,11 @@ class SignInAuthSpaceEndpoint(View):
|
||||
error_message="INSTANCE_NOT_CONFIGURED",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
# set the referer as session to redirect after login
|
||||
@@ -51,9 +51,11 @@ class SignInAuthSpaceEndpoint(View):
|
||||
payload={"email": str(email)},
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
# Validate email
|
||||
@@ -67,9 +69,11 @@ class SignInAuthSpaceEndpoint(View):
|
||||
payload={"email": str(email)},
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
# Existing User
|
||||
@@ -82,9 +86,11 @@ class SignInAuthSpaceEndpoint(View):
|
||||
payload={"email": str(email)},
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
try:
|
||||
@@ -95,13 +101,19 @@ class SignInAuthSpaceEndpoint(View):
|
||||
# Login the user and record his device info
|
||||
user_login(request=request, user=user, is_space=True)
|
||||
# redirect to next path
|
||||
url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params={}
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
|
||||
@@ -117,9 +129,11 @@ class SignUpAuthSpaceEndpoint(View):
|
||||
error_message="INSTANCE_NOT_CONFIGURED",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
email = request.POST.get("email", False)
|
||||
@@ -135,9 +149,11 @@ class SignUpAuthSpaceEndpoint(View):
|
||||
payload={"email": str(email)},
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
# Validate the email
|
||||
email = email.strip().lower()
|
||||
@@ -151,9 +167,11 @@ class SignUpAuthSpaceEndpoint(View):
|
||||
payload={"email": str(email)},
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
# Existing User
|
||||
@@ -166,9 +184,11 @@ class SignUpAuthSpaceEndpoint(View):
|
||||
payload={"email": str(email)},
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
try:
|
||||
@@ -179,11 +199,17 @@ class SignUpAuthSpaceEndpoint(View):
|
||||
# Login the user and record his device info
|
||||
user_login(request=request, user=user, is_space=True)
|
||||
# redirect to referer path
|
||||
url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
|
||||
return HttpResponseRedirect(url)
|
||||
next_path = validate_next_path(next_path=next_path)
|
||||
url = f"{base_host(request=request, is_space=True).rstrip("/")}{next_path}"
|
||||
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
|
||||
return HttpResponseRedirect(url)
|
||||
else:
|
||||
return HttpResponseRedirect(base_host(request=request, is_space=True))
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# Python imports
|
||||
import uuid
|
||||
from urllib.parse import urlencode
|
||||
|
||||
# Django import
|
||||
from django.http import HttpResponseRedirect
|
||||
@@ -15,7 +14,7 @@ from plane.authentication.adapter.error import (
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
AuthenticationException,
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path
|
||||
|
||||
|
||||
class GitHubOauthInitiateSpaceEndpoint(View):
|
||||
@@ -23,9 +22,6 @@ class GitHubOauthInitiateSpaceEndpoint(View):
|
||||
# Get host and next path
|
||||
request.session["host"] = base_host(request=request, is_space=True)
|
||||
next_path = request.GET.get("next_path")
|
||||
if next_path:
|
||||
request.session["next_path"] = str(next_path)
|
||||
|
||||
# Check instance configuration
|
||||
instance = Instance.objects.first()
|
||||
if instance is None or not instance.is_setup_done:
|
||||
@@ -34,9 +30,11 @@ class GitHubOauthInitiateSpaceEndpoint(View):
|
||||
error_message="INSTANCE_NOT_CONFIGURED",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
try:
|
||||
@@ -47,9 +45,11 @@ class GitHubOauthInitiateSpaceEndpoint(View):
|
||||
return HttpResponseRedirect(auth_url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
|
||||
@@ -66,9 +66,11 @@ class GitHubCallbackSpaceEndpoint(View):
|
||||
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
if not code:
|
||||
@@ -77,9 +79,11 @@ class GitHubCallbackSpaceEndpoint(View):
|
||||
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
try:
|
||||
@@ -89,11 +93,14 @@ class GitHubCallbackSpaceEndpoint(View):
|
||||
user_login(request=request, user=user, is_space=True)
|
||||
# Process workspace and project invitations
|
||||
# redirect to referer path
|
||||
url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
|
||||
next_path = validate_next_path(next_path=next_path)
|
||||
url = f"{base_host(request=request, is_space=True).rstrip("/")}{next_path}"
|
||||
return HttpResponseRedirect(url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# Python imports
|
||||
import uuid
|
||||
from urllib.parse import urlencode
|
||||
|
||||
# Django import
|
||||
from django.http import HttpResponseRedirect
|
||||
@@ -15,7 +14,7 @@ from plane.authentication.adapter.error import (
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
AuthenticationException,
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path
|
||||
|
||||
|
||||
class GitLabOauthInitiateSpaceEndpoint(View):
|
||||
@@ -23,8 +22,6 @@ class GitLabOauthInitiateSpaceEndpoint(View):
|
||||
# Get host and next path
|
||||
request.session["host"] = base_host(request=request, is_space=True)
|
||||
next_path = request.GET.get("next_path")
|
||||
if next_path:
|
||||
request.session["next_path"] = str(next_path)
|
||||
|
||||
# Check instance configuration
|
||||
instance = Instance.objects.first()
|
||||
@@ -34,9 +31,11 @@ class GitLabOauthInitiateSpaceEndpoint(View):
|
||||
error_message="INSTANCE_NOT_CONFIGURED",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
try:
|
||||
@@ -47,9 +46,11 @@ class GitLabOauthInitiateSpaceEndpoint(View):
|
||||
return HttpResponseRedirect(auth_url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
|
||||
@@ -66,9 +67,11 @@ class GitLabCallbackSpaceEndpoint(View):
|
||||
error_message="GITLAB_OAUTH_PROVIDER_ERROR",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
if not code:
|
||||
@@ -77,9 +80,11 @@ class GitLabCallbackSpaceEndpoint(View):
|
||||
error_message="GITLAB_OAUTH_PROVIDER_ERROR",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
try:
|
||||
@@ -89,11 +94,14 @@ class GitLabCallbackSpaceEndpoint(View):
|
||||
user_login(request=request, user=user, is_space=True)
|
||||
# Process workspace and project invitations
|
||||
# redirect to referer path
|
||||
url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
|
||||
next_path = validate_next_path(next_path=next_path)
|
||||
url = f"{base_host(request=request, is_space=True).rstrip("/")}{next_path}"
|
||||
return HttpResponseRedirect(url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# Python imports
|
||||
import uuid
|
||||
from urllib.parse import urlencode
|
||||
|
||||
# Django import
|
||||
from django.http import HttpResponseRedirect
|
||||
@@ -15,15 +14,13 @@ from plane.authentication.adapter.error import (
|
||||
AuthenticationException,
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path
|
||||
|
||||
|
||||
class GoogleOauthInitiateSpaceEndpoint(View):
|
||||
def get(self, request):
|
||||
request.session["host"] = base_host(request=request, is_space=True)
|
||||
next_path = request.GET.get("next_path")
|
||||
if next_path:
|
||||
request.session["next_path"] = str(next_path)
|
||||
|
||||
# Check instance configuration
|
||||
instance = Instance.objects.first()
|
||||
@@ -33,9 +30,11 @@ class GoogleOauthInitiateSpaceEndpoint(View):
|
||||
error_message="INSTANCE_NOT_CONFIGURED",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
try:
|
||||
@@ -46,9 +45,11 @@ class GoogleOauthInitiateSpaceEndpoint(View):
|
||||
return HttpResponseRedirect(auth_url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
|
||||
@@ -65,9 +66,11 @@ class GoogleCallbackSpaceEndpoint(View):
|
||||
error_message="GOOGLE_OAUTH_PROVIDER_ERROR",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
if not code:
|
||||
exc = AuthenticationException(
|
||||
@@ -75,9 +78,11 @@ class GoogleCallbackSpaceEndpoint(View):
|
||||
error_message="GOOGLE_OAUTH_PROVIDER_ERROR",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
try:
|
||||
provider = GoogleOAuthProvider(request=request, code=code)
|
||||
@@ -85,11 +90,14 @@ class GoogleCallbackSpaceEndpoint(View):
|
||||
# Login the user and record his device info
|
||||
user_login(request=request, user=user, is_space=True)
|
||||
# redirect to referer path
|
||||
url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
|
||||
next_path = validate_next_path(next_path=next_path)
|
||||
url = f"{base_host(request=request, is_space=True).rstrip("/")}{next_path}"
|
||||
return HttpResponseRedirect(url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
# Python imports
|
||||
from urllib.parse import urlencode
|
||||
|
||||
# Django imports
|
||||
from django.core.validators import validate_email
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.views import View
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
@@ -23,7 +21,7 @@ from plane.authentication.adapter.error import (
|
||||
AuthenticationException,
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts
|
||||
|
||||
|
||||
class MagicGenerateSpaceEndpoint(APIView):
|
||||
@@ -66,9 +64,11 @@ class MagicSignInSpaceEndpoint(View):
|
||||
error_message="MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
existing_user = User.objects.filter(email=email).first()
|
||||
@@ -79,9 +79,11 @@ class MagicSignInSpaceEndpoint(View):
|
||||
error_message="USER_DOES_NOT_EXIST",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
# Active User
|
||||
@@ -93,15 +95,20 @@ class MagicSignInSpaceEndpoint(View):
|
||||
# Login the user and record his device info
|
||||
user_login(request=request, user=user, is_space=True)
|
||||
# redirect to referer path
|
||||
path = str(next_path) if next_path else ""
|
||||
url = f"{base_host(request=request, is_space=True)}{path}"
|
||||
return HttpResponseRedirect(url)
|
||||
next_path = validate_next_path(next_path=next_path)
|
||||
url = f"{base_host(request=request, is_space=True).rstrip("/")}{next_path}"
|
||||
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
|
||||
return HttpResponseRedirect(url)
|
||||
else:
|
||||
return HttpResponseRedirect(base_host(request=request, is_space=True))
|
||||
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
|
||||
@@ -120,9 +127,11 @@ class MagicSignUpSpaceEndpoint(View):
|
||||
error_message="MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
# Existing User
|
||||
existing_user = User.objects.filter(email=email).first()
|
||||
@@ -133,9 +142,11 @@ class MagicSignUpSpaceEndpoint(View):
|
||||
error_message="USER_ALREADY_EXIST",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
try:
|
||||
@@ -146,12 +157,18 @@ class MagicSignUpSpaceEndpoint(View):
|
||||
# Login the user and record his device info
|
||||
user_login(request=request, user=user, is_space=True)
|
||||
# redirect to referer path
|
||||
url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
|
||||
return HttpResponseRedirect(url)
|
||||
next_path = validate_next_path(next_path=next_path)
|
||||
url = f"{base_host(request=request, is_space=True).rstrip("/")}{next_path}"
|
||||
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
|
||||
return HttpResponseRedirect(url)
|
||||
else:
|
||||
return HttpResponseRedirect(base_host(request=request, is_space=True))
|
||||
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.utils import timezone
|
||||
# Module imports
|
||||
from plane.authentication.utils.host import base_host, user_ip
|
||||
from plane.db.models import User
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
from plane.utils.path_validator import get_safe_redirect_url
|
||||
|
||||
|
||||
class SignOutAuthSpaceEndpoint(View):
|
||||
@@ -22,8 +22,14 @@ class SignOutAuthSpaceEndpoint(View):
|
||||
user.save()
|
||||
# Log the user out
|
||||
logout(request)
|
||||
url = f"{base_host(request=request, is_space=True)}{str(validate_next_path(next_path)) if next_path else ''}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
except Exception:
|
||||
url = f"{base_host(request=request, is_space=True)}{str(validate_next_path(next_path)) if next_path else ''}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -67,7 +67,7 @@ def flush_to_mongo_and_delete(
|
||||
mongo_archival_failed = False
|
||||
|
||||
# Try to insert into MongoDB if available
|
||||
if mongo_collection and mongo_available:
|
||||
if mongo_collection is not None and mongo_available:
|
||||
try:
|
||||
mongo_collection.bulk_write([InsertOne(doc) for doc in buffer])
|
||||
except BulkWriteError as bwe:
|
||||
@@ -166,9 +166,9 @@ def process_cleanup_task(
|
||||
def transform_api_log(record: Dict) -> Dict:
|
||||
"""Transform API activity log record."""
|
||||
return {
|
||||
"id": record["id"],
|
||||
"id": str(record["id"]),
|
||||
"created_at": str(record["created_at"]) if record.get("created_at") else None,
|
||||
"token_identifier": record["token_identifier"],
|
||||
"token_identifier": str(record["token_identifier"]),
|
||||
"path": record["path"],
|
||||
"method": record["method"],
|
||||
"query_params": record.get("query_params"),
|
||||
@@ -178,18 +178,18 @@ def transform_api_log(record: Dict) -> Dict:
|
||||
"response_body": record["response_body"],
|
||||
"ip_address": record["ip_address"],
|
||||
"user_agent": record["user_agent"],
|
||||
"created_by_id": record["created_by_id"],
|
||||
"created_by_id": str(record["created_by_id"]),
|
||||
}
|
||||
|
||||
|
||||
def transform_email_log(record: Dict) -> Dict:
|
||||
"""Transform email notification log record."""
|
||||
return {
|
||||
"id": record["id"],
|
||||
"id": str(record["id"]),
|
||||
"created_at": str(record["created_at"]) if record.get("created_at") else None,
|
||||
"receiver_id": record["receiver_id"],
|
||||
"triggered_by_id": record["triggered_by_id"],
|
||||
"entity_identifier": record["entity_identifier"],
|
||||
"receiver_id": str(record["receiver_id"]),
|
||||
"triggered_by_id": str(record["triggered_by_id"]),
|
||||
"entity_identifier": str(record["entity_identifier"]),
|
||||
"entity_name": record["entity_name"],
|
||||
"data": record["data"],
|
||||
"processed_at": (
|
||||
@@ -197,27 +197,27 @@ def transform_email_log(record: Dict) -> Dict:
|
||||
),
|
||||
"sent_at": str(record["sent_at"]) if record.get("sent_at") else None,
|
||||
"entity": record["entity"],
|
||||
"old_value": record["old_value"],
|
||||
"new_value": record["new_value"],
|
||||
"created_by_id": record["created_by_id"],
|
||||
"old_value": str(record["old_value"]),
|
||||
"new_value": str(record["new_value"]),
|
||||
"created_by_id": str(record["created_by_id"]),
|
||||
}
|
||||
|
||||
|
||||
def transform_page_version(record: Dict) -> Dict:
|
||||
"""Transform page version record."""
|
||||
return {
|
||||
"id": record["id"],
|
||||
"id": str(record["id"]),
|
||||
"created_at": str(record["created_at"]) if record.get("created_at") else None,
|
||||
"page_id": record["page_id"],
|
||||
"workspace_id": record["workspace_id"],
|
||||
"owned_by_id": record["owned_by_id"],
|
||||
"page_id": str(record["page_id"]),
|
||||
"workspace_id": str(record["workspace_id"]),
|
||||
"owned_by_id": str(record["owned_by_id"]),
|
||||
"description_html": record["description_html"],
|
||||
"description_binary": record["description_binary"],
|
||||
"description_stripped": record["description_stripped"],
|
||||
"description_json": record["description_json"],
|
||||
"sub_pages_data": record["sub_pages_data"],
|
||||
"created_by_id": record["created_by_id"],
|
||||
"updated_by_id": record["updated_by_id"],
|
||||
"created_by_id": str(record["created_by_id"]),
|
||||
"updated_by_id": str(record["updated_by_id"]),
|
||||
"deleted_at": str(record["deleted_at"]) if record.get("deleted_at") else None,
|
||||
"last_saved_at": (
|
||||
str(record["last_saved_at"]) if record.get("last_saved_at") else None
|
||||
@@ -228,14 +228,14 @@ def transform_page_version(record: Dict) -> Dict:
|
||||
def transform_issue_description_version(record: Dict) -> Dict:
|
||||
"""Transform issue description version record."""
|
||||
return {
|
||||
"id": record["id"],
|
||||
"id": str(record["id"]),
|
||||
"created_at": str(record["created_at"]) if record.get("created_at") else None,
|
||||
"issue_id": record["issue_id"],
|
||||
"workspace_id": record["workspace_id"],
|
||||
"project_id": record["project_id"],
|
||||
"created_by_id": record["created_by_id"],
|
||||
"updated_by_id": record["updated_by_id"],
|
||||
"owned_by_id": record["owned_by_id"],
|
||||
"issue_id": str(record["issue_id"]),
|
||||
"workspace_id": str(record["workspace_id"]),
|
||||
"project_id": str(record["project_id"]),
|
||||
"created_by_id": str(record["created_by_id"]),
|
||||
"updated_by_id": str(record["updated_by_id"]),
|
||||
"owned_by_id": str(record["owned_by_id"]),
|
||||
"last_saved_at": (
|
||||
str(record["last_saved_at"]) if record.get("last_saved_at") else None
|
||||
),
|
||||
|
||||
@@ -30,6 +30,8 @@ def page_version(page_id, existing_instance, user_id):
|
||||
description_binary=page.description_binary,
|
||||
owned_by_id=user_id,
|
||||
last_saved_at=page.updated_at,
|
||||
description_json=page.description,
|
||||
description_stripped=page.description_stripped,
|
||||
)
|
||||
|
||||
# If page versions are greater than 20 delete the oldest one
|
||||
|
||||
@@ -92,6 +92,10 @@ def create_project_and_member(workspace: Workspace) -> Dict[int, uuid.UUID]:
|
||||
name=workspace.name, # Use workspace name
|
||||
identifier=project_identifier,
|
||||
created_by_id=workspace.created_by_id,
|
||||
# Enable all views in seed data
|
||||
cycle_view=True,
|
||||
module_view=True,
|
||||
issue_views_view=True,
|
||||
)
|
||||
|
||||
# Create project members
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 4.2.22 on 2025-08-29 11:31
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("db", "0101_description_descriptionversion"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="page",
|
||||
name="sort_order",
|
||||
field=models.FloatField(default=65535),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="pagelog",
|
||||
name="entity_type",
|
||||
field=models.CharField(
|
||||
blank=True, max_length=30, null=True, verbose_name="Entity Type"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="pagelog",
|
||||
name="entity_identifier",
|
||||
field=models.UUIDField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,75 @@
|
||||
# Generated by Django 4.2.22 on 2025-09-01 14:33
|
||||
|
||||
from django.db import migrations, models
|
||||
from django.contrib.postgres.operations import AddIndexConcurrently
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
atomic = False
|
||||
|
||||
dependencies = [
|
||||
('db', '0102_page_sort_order_pagelog_entity_type_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
AddIndexConcurrently(
|
||||
model_name='fileasset',
|
||||
index=models.Index(fields=['entity_type'], name='asset_entity_type_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='fileasset',
|
||||
index=models.Index(fields=['entity_identifier'], name='asset_entity_identifier_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='fileasset',
|
||||
index=models.Index(fields=['entity_type', 'entity_identifier'], name='asset_entity_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='notification',
|
||||
index=models.Index(fields=['entity_identifier'], name='notif_entity_identifier_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='notification',
|
||||
index=models.Index(fields=['entity_name'], name='notif_entity_name_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='notification',
|
||||
index=models.Index(fields=['read_at'], name='notif_read_at_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='notification',
|
||||
index=models.Index(fields=['receiver', 'read_at'], name='notif_entity_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='pagelog',
|
||||
index=models.Index(fields=['entity_type'], name='pagelog_entity_type_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='pagelog',
|
||||
index=models.Index(fields=['entity_identifier'], name='pagelog_entity_id_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='pagelog',
|
||||
index=models.Index(fields=['entity_name'], name='pagelog_entity_name_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='pagelog',
|
||||
index=models.Index(fields=['entity_type', 'entity_identifier'], name='pagelog_type_id_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='pagelog',
|
||||
index=models.Index(fields=['entity_name', 'entity_identifier'], name='pagelog_name_id_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='userfavorite',
|
||||
index=models.Index(fields=['entity_type'], name='fav_entity_type_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='userfavorite',
|
||||
index=models.Index(fields=['entity_identifier'], name='fav_entity_identifier_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='userfavorite',
|
||||
index=models.Index(fields=['entity_type', 'entity_identifier'], name='fav_entity_idx'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
# Generated by Django 4.2.22 on 2025-09-03 05:18
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0103_fileasset_asset_entity_type_idx_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='cycleuserproperties',
|
||||
name='rich_filters',
|
||||
field=models.JSONField(default=dict),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='exporterhistory',
|
||||
name='rich_filters',
|
||||
field=models.JSONField(blank=True, default=dict, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='issueuserproperty',
|
||||
name='rich_filters',
|
||||
field=models.JSONField(default=dict),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='issueview',
|
||||
name='rich_filters',
|
||||
field=models.JSONField(default=dict),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='moduleuserproperties',
|
||||
name='rich_filters',
|
||||
field=models.JSONField(default=dict),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='workspaceuserproperties',
|
||||
name='rich_filters',
|
||||
field=models.JSONField(default=dict),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 4.2.22 on 2025-09-10 09:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("db", "0104_cycleuserproperties_rich_filters_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="project",
|
||||
name="cycle_view",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="project",
|
||||
name="issue_views_view",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="project",
|
||||
name="module_view",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="user_id",
|
||||
field=models.CharField(db_index=True, max_length=50, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,152 @@
|
||||
# Generated by Django 4.2.22 on 2025-09-12 08:45
|
||||
import uuid
|
||||
import django
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def set_page_sort_order(apps, schema_editor):
|
||||
Page = apps.get_model("db", "Page")
|
||||
|
||||
batch_size = 3000
|
||||
sort_order = 100
|
||||
|
||||
# Get page IDs ordered by name using the historical model
|
||||
# This should include all pages regardless of soft-delete status
|
||||
page_ids = list(Page.objects.all().order_by("name").values_list("id", flat=True))
|
||||
|
||||
updated_pages = []
|
||||
for page_id in page_ids:
|
||||
# Create page instance with minimal data
|
||||
updated_pages.append(Page(id=page_id, sort_order=sort_order))
|
||||
sort_order += 100
|
||||
|
||||
# Bulk update when batch is full
|
||||
if len(updated_pages) >= batch_size:
|
||||
Page.objects.bulk_update(
|
||||
updated_pages, ["sort_order"], batch_size=batch_size
|
||||
)
|
||||
updated_pages = []
|
||||
|
||||
# Update remaining pages
|
||||
if updated_pages:
|
||||
Page.objects.bulk_update(updated_pages, ["sort_order"], batch_size=batch_size)
|
||||
|
||||
|
||||
def reverse_set_page_sort_order(apps, schema_editor):
|
||||
Page = apps.get_model("db", "Page")
|
||||
Page.objects.update(sort_order=Page.DEFAULT_SORT_ORDER)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("db", "0105_alter_project_cycle_view_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ProjectWebhook",
|
||||
fields=[
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True, verbose_name="Last Modified At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"deleted_at",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="Deleted At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_created_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Created By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"project",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="project_%(class)s",
|
||||
to="db.project",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_updated_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Last Modified By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"webhook",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="project_webhooks",
|
||||
to="db.webhook",
|
||||
),
|
||||
),
|
||||
(
|
||||
"workspace",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="workspace_%(class)s",
|
||||
to="db.workspace",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Project Webhook",
|
||||
"verbose_name_plural": "Project Webhooks",
|
||||
"db_table": "project_webhooks",
|
||||
"ordering": ("-created_at",),
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="projectwebhook",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("deleted_at__isnull", True)),
|
||||
fields=("project", "webhook"),
|
||||
name="project_webhook_unique_project_webhook_when_deleted_at_null",
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="projectwebhook",
|
||||
unique_together={("project", "webhook", "deleted_at")},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="issuerelation",
|
||||
name="relation_type",
|
||||
field=models.CharField(
|
||||
default="blocked_by", max_length=20, verbose_name="Issue Relation Type"
|
||||
),
|
||||
),
|
||||
migrations.RunPython(
|
||||
set_page_sort_order, reverse_code=reverse_set_page_sort_order
|
||||
),
|
||||
]
|
||||
@@ -76,6 +76,15 @@ class FileAsset(BaseModel):
|
||||
verbose_name_plural = "File Assets"
|
||||
db_table = "file_assets"
|
||||
ordering = ("-created_at",)
|
||||
indexes = [
|
||||
models.Index(fields=["entity_type"], name="asset_entity_type_idx"),
|
||||
models.Index(
|
||||
fields=["entity_identifier"], name="asset_entity_identifier_idx"
|
||||
),
|
||||
models.Index(
|
||||
fields=["entity_type", "entity_identifier"], name="asset_entity_idx"
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return str(self.asset)
|
||||
|
||||
@@ -139,6 +139,7 @@ class CycleUserProperties(ProjectBaseModel):
|
||||
filters = models.JSONField(default=get_default_filters)
|
||||
display_filters = models.JSONField(default=get_default_display_filters)
|
||||
display_properties = models.JSONField(default=get_default_display_properties)
|
||||
rich_filters = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["cycle", "user", "deleted_at"]
|
||||
|
||||
@@ -56,6 +56,7 @@ class ExporterHistory(BaseModel):
|
||||
related_name="workspace_exporters",
|
||||
)
|
||||
filters = models.JSONField(blank=True, null=True)
|
||||
rich_filters = models.JSONField(default=dict, blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Exporter"
|
||||
|
||||
@@ -41,6 +41,15 @@ class UserFavorite(WorkspaceBaseModel):
|
||||
verbose_name_plural = "User Favorites"
|
||||
db_table = "user_favorites"
|
||||
ordering = ("-created_at",)
|
||||
indexes = [
|
||||
models.Index(fields=["entity_type"], name="fav_entity_type_idx"),
|
||||
models.Index(
|
||||
fields=["entity_identifier"], name="fav_entity_identifier_idx"
|
||||
),
|
||||
models.Index(
|
||||
fields=["entity_type", "entity_identifier"], name="fav_entity_idx"
|
||||
),
|
||||
]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self._state.adding:
|
||||
|
||||
@@ -284,6 +284,7 @@ class IssueRelationChoices(models.TextChoices):
|
||||
BLOCKED_BY = "blocked_by", "Blocked By"
|
||||
START_BEFORE = "start_before", "Start Before"
|
||||
FINISH_BEFORE = "finish_before", "Finish Before"
|
||||
IMPLEMENTED_BY = "implemented_by", "Implemented By"
|
||||
|
||||
|
||||
class IssueRelation(ProjectBaseModel):
|
||||
@@ -295,7 +296,6 @@ class IssueRelation(ProjectBaseModel):
|
||||
)
|
||||
relation_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=IssueRelationChoices.choices,
|
||||
verbose_name="Issue Relation Type",
|
||||
default=IssueRelationChoices.BLOCKED_BY,
|
||||
)
|
||||
@@ -509,6 +509,7 @@ class IssueUserProperty(ProjectBaseModel):
|
||||
filters = models.JSONField(default=get_default_filters)
|
||||
display_filters = models.JSONField(default=get_default_display_filters)
|
||||
display_properties = models.JSONField(default=get_default_display_properties)
|
||||
rich_filters = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue User Property"
|
||||
|
||||
@@ -207,6 +207,7 @@ class ModuleUserProperties(ProjectBaseModel):
|
||||
filters = models.JSONField(default=get_default_filters)
|
||||
display_filters = models.JSONField(default=get_default_display_filters)
|
||||
display_properties = models.JSONField(default=get_default_display_properties)
|
||||
rich_filters = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["module", "user", "deleted_at"]
|
||||
|
||||
@@ -39,6 +39,14 @@ class Notification(BaseModel):
|
||||
verbose_name_plural = "Notifications"
|
||||
db_table = "notifications"
|
||||
ordering = ("-created_at",)
|
||||
indexes = [
|
||||
models.Index(
|
||||
fields=["entity_identifier"], name="notif_entity_identifier_idx"
|
||||
),
|
||||
models.Index(fields=["entity_name"], name="notif_entity_name_idx"),
|
||||
models.Index(fields=["read_at"], name="notif_read_at_idx"),
|
||||
models.Index(fields=["receiver", "read_at"], name="notif_entity_idx"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the notifications"""
|
||||
|
||||
@@ -19,6 +19,7 @@ def get_view_props():
|
||||
class Page(BaseModel):
|
||||
PRIVATE_ACCESS = 1
|
||||
PUBLIC_ACCESS = 0
|
||||
DEFAULT_SORT_ORDER = 65535
|
||||
|
||||
ACCESS_CHOICES = ((PRIVATE_ACCESS, "Private"), (PUBLIC_ACCESS, "Public"))
|
||||
|
||||
@@ -57,6 +58,7 @@ class Page(BaseModel):
|
||||
)
|
||||
moved_to_page = models.UUIDField(null=True, blank=True)
|
||||
moved_to_project = models.UUIDField(null=True, blank=True)
|
||||
sort_order = models.FloatField(default=DEFAULT_SORT_ORDER)
|
||||
|
||||
external_id = models.CharField(max_length=255, null=True, blank=True)
|
||||
external_source = models.CharField(max_length=255, null=True, blank=True)
|
||||
@@ -98,8 +100,11 @@ class PageLog(BaseModel):
|
||||
)
|
||||
transaction = models.UUIDField(default=uuid.uuid4)
|
||||
page = models.ForeignKey(Page, related_name="page_log", on_delete=models.CASCADE)
|
||||
entity_identifier = models.UUIDField(null=True)
|
||||
entity_identifier = models.UUIDField(null=True, blank=True)
|
||||
entity_name = models.CharField(max_length=30, verbose_name="Transaction Type")
|
||||
entity_type = models.CharField(
|
||||
max_length=30, verbose_name="Entity Type", null=True, blank=True
|
||||
)
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_page_log"
|
||||
)
|
||||
@@ -110,6 +115,17 @@ class PageLog(BaseModel):
|
||||
verbose_name_plural = "Page Logs"
|
||||
db_table = "page_logs"
|
||||
ordering = ("-created_at",)
|
||||
indexes = [
|
||||
models.Index(fields=["entity_type"], name="pagelog_entity_type_idx"),
|
||||
models.Index(fields=["entity_identifier"], name="pagelog_entity_id_idx"),
|
||||
models.Index(fields=["entity_name"], name="pagelog_entity_name_idx"),
|
||||
models.Index(
|
||||
fields=["entity_type", "entity_identifier"], name="pagelog_type_id_idx"
|
||||
),
|
||||
models.Index(
|
||||
fields=["entity_name", "entity_identifier"], name="pagelog_name_id_idx"
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.page.name} {self.entity_name}"
|
||||
|
||||
@@ -18,6 +18,12 @@ from .base import BaseModel
|
||||
ROLE_CHOICES = ((20, "Admin"), (15, "Member"), (5, "Guest"))
|
||||
|
||||
|
||||
class ROLE(Enum):
|
||||
ADMIN = 20
|
||||
MEMBER = 15
|
||||
GUEST = 5
|
||||
|
||||
|
||||
class ProjectNetwork(Enum):
|
||||
SECRET = 0
|
||||
PUBLIC = 2
|
||||
@@ -89,9 +95,9 @@ class Project(BaseModel):
|
||||
)
|
||||
emoji = models.CharField(max_length=255, null=True, blank=True)
|
||||
icon_prop = models.JSONField(null=True)
|
||||
module_view = models.BooleanField(default=True)
|
||||
cycle_view = models.BooleanField(default=True)
|
||||
issue_views_view = models.BooleanField(default=True)
|
||||
module_view = models.BooleanField(default=False)
|
||||
cycle_view = models.BooleanField(default=False)
|
||||
issue_views_view = models.BooleanField(default=False)
|
||||
page_view = models.BooleanField(default=True)
|
||||
intake_view = models.BooleanField(default=False)
|
||||
is_time_tracking_enabled = models.BooleanField(default=False)
|
||||
|
||||
@@ -13,7 +13,7 @@ VALID_KEY_CHARS = string.ascii_lowercase + string.digits
|
||||
class Session(AbstractBaseSession):
|
||||
device_info = models.JSONField(null=True, blank=True, default=None)
|
||||
session_key = models.CharField(max_length=128, primary_key=True)
|
||||
user_id = models.CharField(null=True, max_length=50)
|
||||
user_id = models.CharField(null=True, max_length=50, db_index=True)
|
||||
|
||||
@classmethod
|
||||
def get_session_store_class(cls):
|
||||
|
||||
@@ -58,6 +58,7 @@ class IssueView(WorkspaceBaseModel):
|
||||
filters = models.JSONField(default=dict)
|
||||
display_filters = models.JSONField(default=get_default_display_filters)
|
||||
display_properties = models.JSONField(default=get_default_display_properties)
|
||||
rich_filters = models.JSONField(default=dict)
|
||||
access = models.PositiveSmallIntegerField(
|
||||
default=1, choices=((0, "Private"), (1, "Public"))
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.db import models
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import BaseModel
|
||||
from plane.db.models import BaseModel, ProjectBaseModel
|
||||
|
||||
|
||||
def generate_token():
|
||||
@@ -90,3 +90,24 @@ class WebhookLog(BaseModel):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.event_type} {str(self.webhook)}"
|
||||
|
||||
|
||||
|
||||
class ProjectWebhook(ProjectBaseModel):
|
||||
webhook = models.ForeignKey(
|
||||
"db.Webhook", on_delete=models.CASCADE, related_name="project_webhooks"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["project", "webhook", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["project", "webhook"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="project_webhook_unique_project_webhook_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Project Webhook"
|
||||
verbose_name_plural = "Project Webhooks"
|
||||
db_table = "project_webhooks"
|
||||
ordering = ("-created_at",)
|
||||
@@ -332,6 +332,7 @@ class WorkspaceUserProperties(BaseModel):
|
||||
filters = models.JSONField(default=get_default_filters)
|
||||
display_filters = models.JSONField(default=get_default_display_filters)
|
||||
display_properties = models.JSONField(default=get_default_display_properties)
|
||||
rich_filters = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["workspace", "user", "deleted_at"]
|
||||
|
||||
@@ -34,6 +34,7 @@ from plane.authentication.adapter.error import (
|
||||
AuthenticationException,
|
||||
)
|
||||
from plane.utils.ip_address import get_client_ip
|
||||
from plane.utils.path_validator import get_safe_redirect_url
|
||||
|
||||
|
||||
class InstanceAdminEndpoint(BaseAPIView):
|
||||
@@ -392,7 +393,14 @@ class InstanceAdminSignOutEndpoint(View):
|
||||
user.save()
|
||||
# Log the user out
|
||||
logout(request)
|
||||
url = urljoin(base_host(request=request, is_admin=True))
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_admin=True),
|
||||
next_path=""
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
except Exception:
|
||||
return HttpResponseRedirect(base_host(request=request, is_admin=True))
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_admin=True),
|
||||
next_path=""
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.22 on 2025-09-11 08:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("license", "0005_rename_product_instance_edition_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="instance",
|
||||
name="is_current_version_deprecated",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -38,6 +38,8 @@ class Instance(BaseModel):
|
||||
is_signup_screen_visited = models.BooleanField(default=False)
|
||||
is_verified = models.BooleanField(default=False)
|
||||
is_test = models.BooleanField(default=False)
|
||||
# field for validating if the current version is deprecated
|
||||
is_current_version_deprecated = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Instance"
|
||||
|
||||
@@ -465,3 +465,7 @@ if ENABLE_DRF_SPECTACULAR:
|
||||
REST_FRAMEWORK["DEFAULT_SCHEMA_CLASS"] = "drf_spectacular.openapi.AutoSchema"
|
||||
INSTALLED_APPS.append("drf_spectacular")
|
||||
from .openapi import SPECTACULAR_SETTINGS # noqa: F401
|
||||
|
||||
# MongoDB Settings
|
||||
MONGO_DB_URL = os.environ.get("MONGO_DB_URL", False)
|
||||
MONGO_DB_DATABASE = os.environ.get("MONGO_DB_DATABASE", False)
|
||||
|
||||
@@ -118,4 +118,7 @@ class MongoConnection:
|
||||
Returns:
|
||||
bool: True if MongoDB is configured and connected, False otherwise
|
||||
"""
|
||||
|
||||
if cls._client is None:
|
||||
cls._instance = cls()
|
||||
return cls._client is not None and cls._db is not None
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
import base64
|
||||
import nh3
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from bs4 import BeautifulSoup
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
# Maximum allowed size for binary data (10MB)
|
||||
MAX_SIZE = 10 * 1024 * 1024
|
||||
@@ -19,7 +22,8 @@ SUSPICIOUS_BINARY_PATTERNS = [
|
||||
|
||||
def validate_binary_data(data):
|
||||
"""
|
||||
Validate that binary data appears to be valid document format and doesn't contain malicious content.
|
||||
Validate that binary data appears to be a valid document format
|
||||
and doesn't contain malicious content.
|
||||
|
||||
Args:
|
||||
data (bytes or str): The binary data to validate, or base64-encoded string
|
||||
@@ -60,6 +64,147 @@ def validate_binary_data(data):
|
||||
return True, None
|
||||
|
||||
|
||||
# Combine custom components and editor-specific nodes into a single set of tags
|
||||
CUSTOM_TAGS = {
|
||||
# editor node/tag names
|
||||
"mention-component",
|
||||
"label",
|
||||
"input",
|
||||
"image-component",
|
||||
}
|
||||
ALLOWED_TAGS = nh3.ALLOWED_TAGS | CUSTOM_TAGS
|
||||
|
||||
# Merge nh3 defaults with all attributes used across our custom components
|
||||
ATTRIBUTES = {
|
||||
"*": {
|
||||
"class",
|
||||
"id",
|
||||
"title",
|
||||
"role",
|
||||
"aria-label",
|
||||
"aria-hidden",
|
||||
"style",
|
||||
"start",
|
||||
"type",
|
||||
# common editor data-* attributes seen in stored HTML
|
||||
# (wildcards like data-* are NOT supported by nh3; we add known keys
|
||||
# here and dynamically include all data-* seen in the input below)
|
||||
"data-tight",
|
||||
"data-node-type",
|
||||
"data-type",
|
||||
"data-checked",
|
||||
"data-background-color",
|
||||
"data-text-color",
|
||||
"data-name",
|
||||
# callout attributes
|
||||
"data-icon-name",
|
||||
"data-icon-color",
|
||||
"data-background",
|
||||
"data-emoji-unicode",
|
||||
"data-emoji-url",
|
||||
"data-logo-in-use",
|
||||
"data-block-type",
|
||||
},
|
||||
"a": {"href", "target"},
|
||||
# editor node/tag attributes
|
||||
"image-component": {
|
||||
"id",
|
||||
"width",
|
||||
"height",
|
||||
"aspectRatio",
|
||||
"aspectratio",
|
||||
"src",
|
||||
"alignment",
|
||||
},
|
||||
"img": {
|
||||
"width",
|
||||
"height",
|
||||
"aspectRatio",
|
||||
"aspectratio",
|
||||
"alignment",
|
||||
"src",
|
||||
"alt",
|
||||
"title",
|
||||
},
|
||||
"mention-component": {"id", "entity_identifier", "entity_name"},
|
||||
"th": {
|
||||
"colspan",
|
||||
"rowspan",
|
||||
"colwidth",
|
||||
"background",
|
||||
"hideContent",
|
||||
"hidecontent",
|
||||
"style",
|
||||
},
|
||||
"td": {
|
||||
"colspan",
|
||||
"rowspan",
|
||||
"colwidth",
|
||||
"background",
|
||||
"textColor",
|
||||
"textcolor",
|
||||
"hideContent",
|
||||
"hidecontent",
|
||||
"style",
|
||||
},
|
||||
"tr": {"background", "textColor", "textcolor", "style"},
|
||||
"pre": {"language"},
|
||||
"code": {"language", "spellcheck"},
|
||||
"input": {"type", "checked"},
|
||||
}
|
||||
|
||||
SAFE_PROTOCOLS = {"http", "https", "mailto", "tel"}
|
||||
|
||||
|
||||
def _compute_html_sanitization_diff(before_html: str, after_html: str):
|
||||
"""
|
||||
Compute a coarse diff between original and sanitized HTML.
|
||||
|
||||
Returns a dict with:
|
||||
- removed_tags: mapping[tag] -> removed_count
|
||||
- removed_attributes: mapping[tag] -> sorted list of attribute names removed
|
||||
"""
|
||||
try:
|
||||
|
||||
def collect(soup):
|
||||
tag_counts = defaultdict(int)
|
||||
attrs_by_tag = defaultdict(set)
|
||||
for el in soup.find_all(True):
|
||||
tag_name = (el.name or "").lower()
|
||||
if not tag_name:
|
||||
continue
|
||||
tag_counts[tag_name] += 1
|
||||
for attr_name in list(el.attrs.keys()):
|
||||
if isinstance(attr_name, str) and attr_name:
|
||||
attrs_by_tag[tag_name].add(attr_name.lower())
|
||||
return tag_counts, attrs_by_tag
|
||||
|
||||
soup_before = BeautifulSoup(before_html or "", "html.parser")
|
||||
soup_after = BeautifulSoup(after_html or "", "html.parser")
|
||||
|
||||
counts_before, attrs_before = collect(soup_before)
|
||||
counts_after, attrs_after = collect(soup_after)
|
||||
|
||||
removed_tags = {}
|
||||
for tag, cnt_before in counts_before.items():
|
||||
cnt_after = counts_after.get(tag, 0)
|
||||
if cnt_after < cnt_before:
|
||||
removed = cnt_before - cnt_after
|
||||
removed_tags[tag] = removed
|
||||
|
||||
removed_attributes = {}
|
||||
for tag, before_set in attrs_before.items():
|
||||
after_set = attrs_after.get(tag, set())
|
||||
removed = before_set - after_set
|
||||
if removed:
|
||||
removed_attributes[tag] = sorted(list(removed))
|
||||
|
||||
return {"removed_tags": removed_tags, "removed_attributes": removed_attributes}
|
||||
except Exception:
|
||||
# Best-effort only; if diffing fails we don't block the request
|
||||
return {"removed_tags": {}, "removed_attributes": {}}
|
||||
|
||||
|
||||
def validate_html_content(html_content: str):
|
||||
"""
|
||||
Sanitize HTML content using nh3.
|
||||
@@ -73,7 +218,25 @@ def validate_html_content(html_content: str):
|
||||
return False, "HTML content exceeds maximum size limit (10MB)", None
|
||||
|
||||
try:
|
||||
clean_html = nh3.clean(html_content)
|
||||
clean_html = nh3.clean(
|
||||
html_content,
|
||||
tags=ALLOWED_TAGS,
|
||||
attributes=ATTRIBUTES,
|
||||
url_schemes=SAFE_PROTOCOLS,
|
||||
)
|
||||
# Report removals to logger (Sentry) if anything was stripped
|
||||
diff = _compute_html_sanitization_diff(html_content, clean_html)
|
||||
if diff.get("removed_tags") or diff.get("removed_attributes"):
|
||||
try:
|
||||
import json
|
||||
|
||||
summary = json.dumps(diff)
|
||||
except Exception:
|
||||
summary = str(diff)
|
||||
log_exception(
|
||||
f"HTML sanitization removals: {summary}",
|
||||
warning=True,
|
||||
)
|
||||
return True, None, clean_html
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Django imports
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models import Q, UUIDField, Value, QuerySet
|
||||
from django.db.models import Q, UUIDField, Value, QuerySet, OuterRef, Subquery
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
# Module imports
|
||||
@@ -14,6 +14,9 @@ from plane.db.models import (
|
||||
ProjectMember,
|
||||
State,
|
||||
WorkspaceMember,
|
||||
IssueAssignee,
|
||||
ModuleIssue,
|
||||
IssueLabel,
|
||||
)
|
||||
from typing import Optional, Dict, Tuple, Any, Union, List
|
||||
|
||||
@@ -39,33 +42,52 @@ def issue_queryset_grouper(
|
||||
if group_key in GROUP_FILTER_MAPPER:
|
||||
queryset = queryset.filter(GROUP_FILTER_MAPPER[group_key])
|
||||
|
||||
issue_assignee_subquery = Subquery(
|
||||
IssueAssignee.objects.filter(
|
||||
issue_id=OuterRef("pk"),
|
||||
deleted_at__isnull=True,
|
||||
)
|
||||
.values("issue_id")
|
||||
.annotate(arr=ArrayAgg("assignee_id", distinct=True))
|
||||
.values("arr")
|
||||
)
|
||||
|
||||
issue_module_subquery = Subquery(
|
||||
ModuleIssue.objects.filter(
|
||||
issue_id=OuterRef("pk"),
|
||||
deleted_at__isnull=True,
|
||||
module__archived_at__isnull=True,
|
||||
)
|
||||
.values("issue_id")
|
||||
.annotate(arr=ArrayAgg("module_id", distinct=True))
|
||||
.values("arr")
|
||||
)
|
||||
|
||||
issue_label_subquery = Subquery(
|
||||
IssueLabel.objects.filter(issue_id=OuterRef("pk"), deleted_at__isnull=True)
|
||||
.values("issue_id")
|
||||
.annotate(arr=ArrayAgg("label_id", distinct=True))
|
||||
.values("arr")
|
||||
)
|
||||
|
||||
annotations_map: Dict[str, Tuple[str, Q]] = {
|
||||
"assignee_ids": (
|
||||
"assignees__id",
|
||||
~Q(assignees__id__isnull=True) & Q(issue_assignee__deleted_at__isnull=True),
|
||||
"assignee_ids": Coalesce(
|
||||
issue_assignee_subquery, Value([], output_field=ArrayField(UUIDField()))
|
||||
),
|
||||
"label_ids": (
|
||||
"labels__id",
|
||||
~Q(labels__id__isnull=True) & Q(label_issue__deleted_at__isnull=True),
|
||||
"label_ids": Coalesce(
|
||||
issue_label_subquery, Value([], output_field=ArrayField(UUIDField()))
|
||||
),
|
||||
"module_ids": (
|
||||
"issue_module__module_id",
|
||||
(
|
||||
~Q(issue_module__module_id__isnull=True)
|
||||
& Q(issue_module__module__archived_at__isnull=True)
|
||||
& Q(issue_module__deleted_at__isnull=True)
|
||||
),
|
||||
"module_ids": Coalesce(
|
||||
issue_module_subquery, Value([], output_field=ArrayField(UUIDField()))
|
||||
),
|
||||
}
|
||||
|
||||
default_annotations: Dict[str, Any] = {
|
||||
key: Coalesce(
|
||||
ArrayAgg(field, distinct=True, filter=condition),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
)
|
||||
for key, (field, condition) in annotations_map.items()
|
||||
if FIELD_MAPPER.get(key) != group_by or FIELD_MAPPER.get(key) != sub_group_by
|
||||
}
|
||||
default_annotations: Dict[str, Any] = {}
|
||||
|
||||
for key, expression in annotations_map.items():
|
||||
if FIELD_MAPPER.get(key) in {group_by, sub_group_by}:
|
||||
continue
|
||||
default_annotations[key] = expression
|
||||
|
||||
return queryset.annotate(**default_annotations)
|
||||
|
||||
|
||||
@@ -476,6 +476,8 @@ def filter_subscribed_issues(params, issue_filter, method, prefix=""):
|
||||
issue_filter[f"{prefix}issue_subscribers__subscriber_id__in"] = params.get(
|
||||
"subscriber"
|
||||
)
|
||||
issue_filter[f"{prefix}issue_subscribers__deleted_at__isnull"] = True
|
||||
|
||||
return issue_filter
|
||||
|
||||
|
||||
|
||||
@@ -6,12 +6,14 @@ def get_inverse_relation(relation_type):
|
||||
"blocking": "blocked_by",
|
||||
"start_before": "start_after",
|
||||
"finish_before": "finish_after",
|
||||
"implemented_by": "implements",
|
||||
"implements": "implemented_by",
|
||||
}
|
||||
return relation_mapping.get(relation_type, relation_type)
|
||||
|
||||
|
||||
def get_actual_relation(relation_type):
|
||||
# This function is used to get the actual relation type which is store in database
|
||||
# This function is used to get the actual relation type which is stored in database
|
||||
actual_relation = {
|
||||
"start_after": "start_before",
|
||||
"finish_after": "finish_before",
|
||||
@@ -19,6 +21,8 @@ def get_actual_relation(relation_type):
|
||||
"blocked_by": "blocked_by",
|
||||
"start_before": "start_before",
|
||||
"finish_before": "finish_before",
|
||||
"implemented_by": "implemented_by",
|
||||
"implements": "implemented_by",
|
||||
}
|
||||
|
||||
return actual_relation.get(relation_type, relation_type)
|
||||
|
||||
@@ -1,21 +1,125 @@
|
||||
# Django imports
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
from django.conf import settings
|
||||
|
||||
# Python imports
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def _contains_suspicious_patterns(path: str) -> bool:
|
||||
"""
|
||||
Check for suspicious patterns that might indicate malicious intent.
|
||||
|
||||
Args:
|
||||
path (str): The path to check
|
||||
|
||||
Returns:
|
||||
bool: True if suspicious patterns found, False otherwise
|
||||
"""
|
||||
suspicious_patterns = [
|
||||
r'javascript:', # JavaScript injection
|
||||
r'data:', # Data URLs
|
||||
r'vbscript:', # VBScript injection
|
||||
r'file:', # File protocol
|
||||
r'ftp:', # FTP protocol
|
||||
r'%2e%2e', # URL encoded path traversal
|
||||
r'%2f%2f', # URL encoded double slash
|
||||
r'%5c%5c', # URL encoded backslashes
|
||||
r'<script', # Script tags
|
||||
r'<iframe', # Iframe tags
|
||||
r'<object', # Object tags
|
||||
r'<embed', # Embed tags
|
||||
r'<form', # Form tags
|
||||
r'onload=', # Event handlers
|
||||
r'onerror=', # Event handlers
|
||||
r'onclick=', # Event handlers
|
||||
]
|
||||
|
||||
path_lower = path.lower()
|
||||
for pattern in suspicious_patterns:
|
||||
if pattern in path_lower:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_allowed_hosts() -> list[str]:
|
||||
"""Get the allowed hosts from the settings."""
|
||||
base_origin = settings.WEB_URL or settings.APP_BASE_URL
|
||||
allowed_hosts = [base_origin]
|
||||
if settings.ADMIN_BASE_URL:
|
||||
# Get only the host
|
||||
host = urlparse(settings.ADMIN_BASE_URL).netloc
|
||||
allowed_hosts.append(host)
|
||||
if settings.SPACE_BASE_URL:
|
||||
# Get only the host
|
||||
host = urlparse(settings.SPACE_BASE_URL).netloc
|
||||
allowed_hosts.append(host)
|
||||
return allowed_hosts
|
||||
|
||||
|
||||
def validate_next_path(next_path: str) -> str:
|
||||
"""Validates that next_path is a valid path and extracts only the path component."""
|
||||
"""Validates that next_path is a safe relative path for redirection."""
|
||||
# Browsers interpret backslashes as forward slashes. Remove all backslashes.
|
||||
if not next_path or not isinstance(next_path, str):
|
||||
return ""
|
||||
|
||||
|
||||
# Limit input length to prevent DoS attacks
|
||||
if len(next_path) > 500:
|
||||
return ""
|
||||
|
||||
|
||||
next_path = next_path.replace("\\", "")
|
||||
parsed_url = urlparse(next_path)
|
||||
|
||||
# Ensure next_path is not an absolute URL
|
||||
# Block absolute URLs or anything with scheme/netloc
|
||||
if parsed_url.scheme or parsed_url.netloc:
|
||||
next_path = parsed_url.path # Extract only the path component
|
||||
|
||||
# Ensure it starts with a forward slash (indicating a valid relative path)
|
||||
if not next_path.startswith("/"):
|
||||
# Must start with a forward slash and not be empty
|
||||
if not next_path or not next_path.startswith("/"):
|
||||
return ""
|
||||
|
||||
# Ensure it does not contain dangerous path traversal sequences
|
||||
# Prevent path traversal
|
||||
if ".." in next_path:
|
||||
return ""
|
||||
|
||||
# Additional security checks
|
||||
if _contains_suspicious_patterns(next_path):
|
||||
return ""
|
||||
|
||||
return next_path
|
||||
|
||||
|
||||
def get_safe_redirect_url(base_url: str, next_path: str = "", params: dict = {}) -> str:
|
||||
"""
|
||||
Safely construct a redirect URL with validated next_path.
|
||||
|
||||
Args:
|
||||
base_url (str): The base URL to redirect to
|
||||
next_path (str): The next path to append
|
||||
params (dict): The parameters to append
|
||||
Returns:
|
||||
str: The safe redirect URL
|
||||
"""
|
||||
from urllib.parse import urlencode, quote
|
||||
|
||||
# Validate the next path
|
||||
validated_path = validate_next_path(next_path)
|
||||
|
||||
# Add the next path to the parameters
|
||||
base_url = base_url.rstrip('/')
|
||||
if params:
|
||||
encoded_params = urlencode(params)
|
||||
url = f"{base_url}/?next_path={validated_path}&{encoded_params}"
|
||||
else:
|
||||
url = f"{base_url}/?next_path={validated_path}"
|
||||
|
||||
# Check if the URL is allowed
|
||||
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
|
||||
return url
|
||||
|
||||
# Return the base URL if the URL is not allowed
|
||||
return base_url
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# base requirements
|
||||
|
||||
# django
|
||||
Django==4.2.22
|
||||
Django==4.2.24
|
||||
# rest framework
|
||||
djangorestframework==3.15.2
|
||||
# postgres
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.react-router
|
||||
build
|
||||
node_modules
|
||||
README.md
|
||||
@@ -0,0 +1,6 @@
|
||||
.DS_Store
|
||||
/node_modules/
|
||||
|
||||
# React Router
|
||||
/.react-router/
|
||||
/build/
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
FROM node:20-alpine AS development-dependencies-env
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
RUN npm ci
|
||||
|
||||
FROM node:20-alpine AS production-dependencies-env
|
||||
COPY ./package.json package-lock.json /app/
|
||||
WORKDIR /app
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
FROM node:20-alpine AS build-env
|
||||
COPY . /app/
|
||||
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
|
||||
WORKDIR /app
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
COPY ./package.json package-lock.json /app/
|
||||
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
|
||||
COPY --from=build-env /app/build /app/build
|
||||
WORKDIR /app
|
||||
CMD ["npm", "run", "start"]
|
||||
@@ -0,0 +1,87 @@
|
||||
# Welcome to React Router!
|
||||
|
||||
A modern, production-ready template for building full-stack React applications using React Router.
|
||||
|
||||
[](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default)
|
||||
|
||||
## Features
|
||||
|
||||
- 🚀 Server-side rendering
|
||||
- ⚡️ Hot Module Replacement (HMR)
|
||||
- 📦 Asset bundling and optimization
|
||||
- 🔄 Data loading and mutations
|
||||
- 🔒 TypeScript by default
|
||||
- 🎉 TailwindCSS for styling
|
||||
- 📖 [React Router docs](https://reactrouter.com/)
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Installation
|
||||
|
||||
Install the dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
Start the development server with HMR:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Your application will be available at `http://localhost:5173`.
|
||||
|
||||
## Building for Production
|
||||
|
||||
Create a production build:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
To build and run using Docker:
|
||||
|
||||
```bash
|
||||
docker build -t my-app .
|
||||
|
||||
# Run the container
|
||||
docker run -p 3000:3000 my-app
|
||||
```
|
||||
|
||||
The containerized application can be deployed to any platform that supports Docker, including:
|
||||
|
||||
- AWS ECS
|
||||
- Google Cloud Run
|
||||
- Azure Container Apps
|
||||
- Digital Ocean App Platform
|
||||
- Fly.io
|
||||
- Railway
|
||||
|
||||
### DIY Deployment
|
||||
|
||||
If you're familiar with deploying Node applications, the built-in app server is production-ready.
|
||||
|
||||
Make sure to deploy the output of `npm run build`
|
||||
|
||||
```
|
||||
├── package.json
|
||||
├── package-lock.json (or pnpm-lock.yaml, or bun.lockb)
|
||||
├── build/
|
||||
│ ├── client/ # Static assets
|
||||
│ └── server/ # Server-side code
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer.
|
||||
|
||||
---
|
||||
|
||||
Built with ❤️ using React Router.
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
type InstanceProviderProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const InstanceProvider: FC<InstanceProviderProps> = observer((props) => {
|
||||
const { children } = props;
|
||||
// store hooks
|
||||
const { fetchInstanceInfo } = useInstance();
|
||||
// fetching instance details
|
||||
useSWR("INSTANCE_DETAILS", () => fetchInstanceInfo(), {
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
errorRetryCount: 0,
|
||||
});
|
||||
|
||||
return <>{children}</>;
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration, type MetaFunction } from "react-router";
|
||||
import type { Route } from "./+types/root";
|
||||
import "@/styles/globals.css";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { ToastWithTheme } from "./toast.provider";
|
||||
import { StoreProvider } from "./store.provider";
|
||||
import { InstanceProvider } from "./instance.provider";
|
||||
import { UserProvider } from "./user.provider";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{
|
||||
title: "Plane | Simple, extensible, open-source project management tool.",
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
content:
|
||||
"Open-source project management tool to manage work items, sprints, and product roadmaps with peace of mind.",
|
||||
},
|
||||
{
|
||||
name: "openGraph:title",
|
||||
content: "Plane | Simple, extensible, open-source project management tool.",
|
||||
},
|
||||
{
|
||||
name: "openGraph:description",
|
||||
content:
|
||||
"Open-source project management tool to manage work items, sprints, and product roadmaps with peace of mind.",
|
||||
},
|
||||
{ name: "openGraph:url", content: "https://plane.so/" },
|
||||
{
|
||||
name: "keywords",
|
||||
content:
|
||||
"software development, customer feedback, software, accelerate, code management, release management, project management, work items tracking, agile, scrum, kanban, collaboration",
|
||||
},
|
||||
{ name: "twitter:site", content: "@planepowers" },
|
||||
{
|
||||
name: "twitter:title",
|
||||
content: "Plane | Simple, extensible, open-source project management tool.",
|
||||
},
|
||||
{
|
||||
name: "twitter:description",
|
||||
content:
|
||||
"Open-source project management tool to manage work items, sprints, and product roadmaps with peace of mind.",
|
||||
},
|
||||
{ name: "twitter:url", content: "https://plane.so/" },
|
||||
{ name: "twitter:image", content: "https://plane.so/og-image.png" },
|
||||
{ name: "twitter:image:alt", content: "Plane - Modern project management" },
|
||||
{ name: "twitter:image:width", content: "1200" },
|
||||
{ name: "twitter:image:height", content: "630" },
|
||||
];
|
||||
};
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href={`/favicon/apple-touch-icon.png`} />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href={`/favicon/favicon-32x32.png`} />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href={`/favicon/favicon-16x16.png`} />
|
||||
<link rel="manifest" href={`/site.webmanifest.json`} />
|
||||
<link rel="shortcut icon" href={`/favicon/favicon.ico`} />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body className={`antialiased`}>
|
||||
{/* <ThemeProvider themes={["light", "dark"]} defaultTheme="system" enableSystem>
|
||||
|
||||
</ThemeProvider> */}
|
||||
|
||||
{children}
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
/* <ThemeProvider themes={["light", "dark"]} defaultTheme="system" enableSystem>
|
||||
<ToastWithTheme />
|
||||
<StoreProvider>
|
||||
<InstanceProvider>
|
||||
<UserProvider>{children}</UserProvider>
|
||||
</InstanceProvider>
|
||||
</StoreProvider>
|
||||
</ThemeProvider> */
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
let message = "Oops!";
|
||||
let details = "An unexpected error occurred.";
|
||||
let stack: string | undefined;
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
message = error.status === 404 ? "404" : "Error";
|
||||
details = error.status === 404 ? "The requested page could not be found." : error.statusText || details;
|
||||
} else if (import.meta.env.DEV && error && error instanceof Error) {
|
||||
details = error.message;
|
||||
stack = error.stack;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="pt-16 p-4 container mx-auto">
|
||||
<h1>{message}</h1>
|
||||
<p>{details}</p>
|
||||
{stack && (
|
||||
<pre className="w-full p-4 overflow-x-auto">
|
||||
<code>{stack}</code>
|
||||
</pre>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { type RouteConfig } from "@react-router/dev/routes";
|
||||
import { flatRoutes } from "@react-router/fs-routes";
|
||||
|
||||
export default flatRoutes() satisfies RouteConfig;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { InstanceSignInForm } from "@/components/instance/sign-in/form";
|
||||
|
||||
export default function HomePage() {
|
||||
return <InstanceSignInForm />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { InstanceSignInForm } from "@/components/instance/sign-in/form";
|
||||
|
||||
export default function GeneralPage() {
|
||||
return <InstanceSignInForm />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Outlet } from "react-router";
|
||||
|
||||
export default function GeneralLayout() {
|
||||
return (
|
||||
<div className="relative z-10 flex flex-col items-center w-screen h-screen overflow-hidden overflow-y-auto pt-6 pb-10 px-8">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { createContext } from "react";
|
||||
// plane admin store
|
||||
import { RootStore } from "@/plane-admin/store/root.store";
|
||||
|
||||
let rootStore = new RootStore();
|
||||
|
||||
export const StoreContext = createContext(rootStore);
|
||||
|
||||
function initializeStore(initialData = {}) {
|
||||
const singletonRootStore = rootStore ?? new RootStore();
|
||||
// If your page has Next.js data fetching methods that use a Mobx store, it will
|
||||
// get hydrated here, check `pages/ssg.js` and `pages/ssr.js` for more details
|
||||
if (initialData) {
|
||||
singletonRootStore.hydrate(initialData);
|
||||
}
|
||||
// For SSG and SSR always create a new store
|
||||
if (typeof window === "undefined") return singletonRootStore;
|
||||
// Create the store once in the client
|
||||
if (!rootStore) rootStore = singletonRootStore;
|
||||
return singletonRootStore;
|
||||
}
|
||||
|
||||
export type StoreProviderProps = {
|
||||
children: React.ReactNode;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
initialState?: any;
|
||||
};
|
||||
|
||||
export const StoreProvider = ({ children, initialState = {} }: StoreProviderProps) => {
|
||||
const store = initializeStore(initialState);
|
||||
return <StoreContext.Provider value={store}>{children}</StoreContext.Provider>;
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
// import { Toast } from "@plane/propel/toast";
|
||||
// import { resolveGeneralTheme } from "@plane/utils";
|
||||
|
||||
export const ToastWithTheme = () => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
// return <Toast theme={resolveGeneralTheme(resolvedTheme)} />;
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
// hooks
|
||||
import { useInstance, useTheme, useUser } from "@/hooks/store";
|
||||
|
||||
interface IUserProvider {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const UserProvider: FC<IUserProvider> = observer(({ children }) => {
|
||||
// hooks
|
||||
const { isSidebarCollapsed, toggleSidebar } = useTheme();
|
||||
const { currentUser, fetchCurrentUser } = useUser();
|
||||
const { fetchInstanceAdmins } = useInstance();
|
||||
|
||||
useSWR("CURRENT_USER", () => fetchCurrentUser(), {
|
||||
shouldRetryOnError: false,
|
||||
});
|
||||
|
||||
useSWR("INSTANCE_ADMINS", () => fetchInstanceAdmins());
|
||||
|
||||
useEffect(() => {
|
||||
// Only access localStorage after component has mounted to prevent hydration mismatch
|
||||
if (typeof window !== "undefined") {
|
||||
const localValue = localStorage.getItem("god_mode_sidebar_collapsed");
|
||||
const localBoolValue = localValue ? (localValue === "true" ? true : false) : false;
|
||||
if (isSidebarCollapsed === undefined && localBoolValue != isSidebarCollapsed) toggleSidebar(localBoolValue);
|
||||
}
|
||||
}, [isSidebarCollapsed, currentUser, toggleSidebar]);
|
||||
|
||||
return <>{children}</>;
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { KeyRound, Mails } from "lucide-react";
|
||||
// types
|
||||
import type {
|
||||
TGetBaseAuthenticationModeProps,
|
||||
TInstanceAuthenticationMethodKeys,
|
||||
TInstanceAuthenticationModes,
|
||||
} from "@plane/types";
|
||||
import { resolveGeneralTheme } from "@plane/utils";
|
||||
// components
|
||||
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
|
||||
import { EmailCodesConfiguration } from "@/components/authentication/email-config-switch";
|
||||
import { GithubConfiguration } from "@/components/authentication/github-config";
|
||||
import { GitlabConfiguration } from "@/components/authentication/gitlab-config";
|
||||
import { GoogleConfiguration } from "@/components/authentication/google-config";
|
||||
import { PasswordLoginConfiguration } from "@/components/authentication/password-config-switch";
|
||||
// plane admin components
|
||||
import { UpgradeButton } from "@/plane-admin/components/common";
|
||||
// assets
|
||||
import githubLightModeImage from "@/public/logos/github-black.png";
|
||||
import githubDarkModeImage from "@/public/logos/github-white.png";
|
||||
import GitlabLogo from "@/public/logos/gitlab-logo.svg";
|
||||
import GoogleLogo from "@/public/logos/google-logo.svg";
|
||||
import OIDCLogo from "@/public/logos/oidc-logo.svg";
|
||||
import SAMLLogo from "@/public/logos/saml-logo.svg";
|
||||
|
||||
export type TAuthenticationModeProps = {
|
||||
disabled: boolean;
|
||||
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
|
||||
};
|
||||
|
||||
// Authentication methods
|
||||
export const getAuthenticationModes: (props: TGetBaseAuthenticationModeProps) => TInstanceAuthenticationModes[] = ({
|
||||
disabled,
|
||||
updateConfig,
|
||||
resolvedTheme,
|
||||
}) => [
|
||||
{
|
||||
key: "unique-codes",
|
||||
name: "Unique codes",
|
||||
description:
|
||||
"Log in or sign up for Plane using codes sent via email. You need to have set up SMTP to use this method.",
|
||||
icon: <Mails className="h-6 w-6 p-0.5 text-custom-text-300/80" />,
|
||||
config: <EmailCodesConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
key: "passwords-login",
|
||||
name: "Passwords",
|
||||
description: "Allow members to create accounts with passwords and use it with their email addresses to sign in.",
|
||||
icon: <KeyRound className="h-6 w-6 p-0.5 text-custom-text-300/80" />,
|
||||
config: <PasswordLoginConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
key: "google",
|
||||
name: "Google",
|
||||
description: "Allow members to log in or sign up for Plane with their Google accounts.",
|
||||
icon: <img src={GoogleLogo} height={20} width={20} alt="Google Logo" />,
|
||||
config: <GoogleConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
key: "github",
|
||||
name: "GitHub",
|
||||
description: "Allow members to log in or sign up for Plane with their GitHub accounts.",
|
||||
icon: (
|
||||
<img
|
||||
src={resolveGeneralTheme(resolvedTheme) === "dark" ? githubDarkModeImage : githubLightModeImage}
|
||||
height={20}
|
||||
width={20}
|
||||
alt="GitHub Logo"
|
||||
/>
|
||||
),
|
||||
config: <GithubConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
key: "gitlab",
|
||||
name: "GitLab",
|
||||
description: "Allow members to log in or sign up to plane with their GitLab accounts.",
|
||||
icon: <img src={GitlabLogo} height={20} width={20} alt="GitLab Logo" />,
|
||||
config: <GitlabConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
key: "oidc",
|
||||
name: "OIDC",
|
||||
description: "Authenticate your users via the OpenID Connect protocol.",
|
||||
icon: <img src={OIDCLogo} height={22} width={22} alt="OIDC Logo" />,
|
||||
config: <UpgradeButton />,
|
||||
unavailable: true,
|
||||
},
|
||||
{
|
||||
key: "saml",
|
||||
name: "SAML",
|
||||
description: "Authenticate your users via the Security Assertion Markup Language protocol.",
|
||||
icon: <img src={SAMLLogo} height={22} width={22} alt="SAML Logo" className="pl-0.5" />,
|
||||
config: <UpgradeButton />,
|
||||
unavailable: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const AuthenticationModes: React.FC<TAuthenticationModeProps> = observer((props) => {
|
||||
const { disabled, updateConfig } = props;
|
||||
// next-themes
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
{getAuthenticationModes({ disabled, updateConfig, resolvedTheme }).map((method) => (
|
||||
<AuthenticationMethodCard
|
||||
key={method.key}
|
||||
name={method.name}
|
||||
description={method.description}
|
||||
icon={method.icon}
|
||||
config={method.config}
|
||||
disabled={disabled}
|
||||
unavailable={method.unavailable}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./authentication-modes";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./upgrade-button";
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
// icons
|
||||
import { SquareArrowOutUpRight } from "lucide-react";
|
||||
// plane internal packages
|
||||
import { getButtonStyling } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
export const UpgradeButton: React.FC = () => (
|
||||
<a href="https://plane.so/pricing?mode=self-hosted" target="_blank" className={cn(getButtonStyling("primary", "sm"))}>
|
||||
Upgrade
|
||||
<SquareArrowOutUpRight className="h-3.5 w-3.5 p-0.5" />
|
||||
</a>
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
import { enableStaticRendering } from "mobx-react";
|
||||
// stores
|
||||
import { CoreRootStore } from "@/store/root.store";
|
||||
|
||||
enableStaticRendering(typeof window === "undefined");
|
||||
|
||||
export class RootStore extends CoreRootStore {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
hydrate(initialData: any) {
|
||||
super.hydrate(initialData);
|
||||
}
|
||||
|
||||
resetOnSignOut() {
|
||||
super.resetOnSignOut();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
// helpers
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
config: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
withBorder?: boolean;
|
||||
unavailable?: boolean;
|
||||
};
|
||||
|
||||
export const AuthenticationMethodCard: FC<Props> = (props) => {
|
||||
const { name, description, icon, config, disabled = false, withBorder = true, unavailable = false } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("w-full flex items-center gap-14 rounded", {
|
||||
"px-4 py-3 border border-custom-border-200": withBorder,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={cn("flex grow items-center gap-4", {
|
||||
"opacity-50": unavailable,
|
||||
})}
|
||||
>
|
||||
<div className="shrink-0">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-custom-background-80">{icon}</div>
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div
|
||||
className={cn("font-medium leading-5 text-custom-text-100", {
|
||||
"text-sm": withBorder,
|
||||
"text-xl": !withBorder,
|
||||
})}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
<div
|
||||
className={cn("font-normal leading-5 text-custom-text-300", {
|
||||
"text-xs": withBorder,
|
||||
"text-sm": !withBorder,
|
||||
})}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`shrink-0 ${disabled && "opacity-70"}`}>{config}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
|
||||
import { ToggleSwitch } from "@plane/ui";
|
||||
import { useInstance } from "@/hooks/store";
|
||||
// ui
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
disabled: boolean;
|
||||
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
|
||||
};
|
||||
|
||||
export const EmailCodesConfiguration: React.FC<Props> = observer((props) => {
|
||||
const { disabled, updateConfig } = props;
|
||||
// store
|
||||
const { formattedConfig } = useInstance();
|
||||
// derived values
|
||||
const enableMagicLogin = formattedConfig?.ENABLE_MAGIC_LINK_LOGIN ?? "";
|
||||
|
||||
return (
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableMagicLogin))}
|
||||
onChange={() => {
|
||||
const newEnableMagicLogin = Boolean(parseInt(enableMagicLogin)) === true ? "0" : "1";
|
||||
updateConfig("ENABLE_MAGIC_LINK_LOGIN", newEnableMagicLogin);
|
||||
}}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Link } from "react-router";
|
||||
// icons
|
||||
import { Settings2 } from "lucide-react";
|
||||
// plane internal packages
|
||||
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
|
||||
import { ToggleSwitch, getButtonStyling } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
disabled: boolean;
|
||||
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
|
||||
};
|
||||
|
||||
export const GithubConfiguration: React.FC<Props> = observer((props) => {
|
||||
const { disabled, updateConfig } = props;
|
||||
// store
|
||||
const { formattedConfig } = useInstance();
|
||||
// derived values
|
||||
const enableGithubConfig = formattedConfig?.IS_GITHUB_ENABLED ?? "";
|
||||
const isGithubConfigured = !!formattedConfig?.GITHUB_CLIENT_ID && !!formattedConfig?.GITHUB_CLIENT_SECRET;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isGithubConfigured ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/authentication/github" className={cn(getButtonStyling("link-primary", "md"), "font-medium")}>
|
||||
Edit
|
||||
</Link>
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableGithubConfig))}
|
||||
onChange={() => {
|
||||
const newEnableGithubConfig = Boolean(parseInt(enableGithubConfig)) === true ? "0" : "1";
|
||||
updateConfig("IS_GITHUB_ENABLED", newEnableGithubConfig);
|
||||
}}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
to="/authentication/github"
|
||||
className={cn(getButtonStyling("neutral-primary", "sm"), "text-custom-text-300")}
|
||||
>
|
||||
<Settings2 className="h-4 w-4 p-0.5 text-custom-text-300/80" />
|
||||
Configure
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Link } from "react-router";
|
||||
// icons
|
||||
import { Settings2 } from "lucide-react";
|
||||
// plane internal packages
|
||||
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
|
||||
import { ToggleSwitch, getButtonStyling } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
disabled: boolean;
|
||||
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
|
||||
};
|
||||
|
||||
export const GitlabConfiguration: React.FC<Props> = observer((props) => {
|
||||
const { disabled, updateConfig } = props;
|
||||
// store
|
||||
const { formattedConfig } = useInstance();
|
||||
// derived values
|
||||
const enableGitlabConfig = formattedConfig?.IS_GITLAB_ENABLED ?? "";
|
||||
const isGitlabConfigured = !!formattedConfig?.GITLAB_CLIENT_ID && !!formattedConfig?.GITLAB_CLIENT_SECRET;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isGitlabConfigured ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/authentication/gitlab" className={cn(getButtonStyling("link-primary", "md"), "font-medium")}>
|
||||
Edit
|
||||
</Link>
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableGitlabConfig))}
|
||||
onChange={() => {
|
||||
const newEnableGitlabConfig = Boolean(parseInt(enableGitlabConfig)) === true ? "0" : "1";
|
||||
updateConfig("IS_GITLAB_ENABLED", newEnableGitlabConfig);
|
||||
}}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
to="/authentication/gitlab"
|
||||
className={cn(getButtonStyling("neutral-primary", "sm"), "text-custom-text-300")}
|
||||
>
|
||||
<Settings2 className="h-4 w-4 p-0.5 text-custom-text-300/80" />
|
||||
Configure
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Link } from "react-router";
|
||||
// icons
|
||||
import { Settings2 } from "lucide-react";
|
||||
// plane internal packages
|
||||
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
|
||||
import { ToggleSwitch, getButtonStyling } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
disabled: boolean;
|
||||
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
|
||||
};
|
||||
|
||||
export const GoogleConfiguration: React.FC<Props> = observer((props) => {
|
||||
const { disabled, updateConfig } = props;
|
||||
// store
|
||||
const { formattedConfig } = useInstance();
|
||||
// derived values
|
||||
const enableGoogleConfig = formattedConfig?.IS_GOOGLE_ENABLED ?? "";
|
||||
const isGoogleConfigured = !!formattedConfig?.GOOGLE_CLIENT_ID && !!formattedConfig?.GOOGLE_CLIENT_SECRET;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isGoogleConfigured ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/authentication/google" className={cn(getButtonStyling("link-primary", "md"), "font-medium")}>
|
||||
Edit
|
||||
</Link>
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableGoogleConfig))}
|
||||
onChange={() => {
|
||||
const newEnableGoogleConfig = Boolean(parseInt(enableGoogleConfig)) === true ? "0" : "1";
|
||||
updateConfig("IS_GOOGLE_ENABLED", newEnableGoogleConfig);
|
||||
}}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
to="/authentication/google"
|
||||
className={cn(getButtonStyling("neutral-primary", "sm"), "text-custom-text-300")}
|
||||
>
|
||||
<Settings2 className="h-4 w-4 p-0.5 text-custom-text-300/80" />
|
||||
Configure
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Mails, KeyRound } from "lucide-react";
|
||||
import { type TInstanceAuthenticationModes } from "@plane/types";
|
||||
import { type TGetBaseAuthenticationModeProps } from "@plane/types";
|
||||
import { EmailCodesConfiguration } from "@/components/authentication/email-config-switch";
|
||||
import { PasswordLoginConfiguration } from "@/components/authentication/password-config-switch";
|
||||
import { GoogleConfiguration } from "@/components/authentication/google-config";
|
||||
import { GithubConfiguration } from "@/components/authentication/github-config";
|
||||
import { GitlabConfiguration } from "@/components/authentication/gitlab-config";
|
||||
// images
|
||||
import githubLightModeImage from "@/public/logos/github-black.png";
|
||||
import githubDarkModeImage from "@/public/logos/github-white.png";
|
||||
import GitlabLogo from "@/public/logos/gitlab-logo.svg";
|
||||
import GoogleLogo from "@/public/logos/google-logo.svg";
|
||||
|
||||
export const getBaseAuthenticationModes: (props: TGetBaseAuthenticationModeProps) => TInstanceAuthenticationModes[] = ({
|
||||
disabled,
|
||||
updateConfig,
|
||||
resolvedTheme,
|
||||
}) => [
|
||||
{
|
||||
key: "unique-codes",
|
||||
name: "Unique codes",
|
||||
description:
|
||||
"Log in or sign up for Plane using codes sent via email. You need to have set up SMTP to use this method.",
|
||||
icon: <Mails className="h-6 w-6 p-0.5 text-custom-text-300/80" />,
|
||||
config: <EmailCodesConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
key: "passwords-login",
|
||||
name: "Passwords",
|
||||
description: "Allow members to create accounts with passwords and use it with their email addresses to sign in.",
|
||||
icon: <KeyRound className="h-6 w-6 p-0.5 text-custom-text-300/80" />,
|
||||
config: <PasswordLoginConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
key: "google",
|
||||
name: "Google",
|
||||
description: "Allow members to log in or sign up for Plane with their Google accounts.",
|
||||
icon: <img src={GoogleLogo} height={20} width={20} alt="Google Logo" />,
|
||||
config: <GoogleConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
key: "github",
|
||||
name: "GitHub",
|
||||
description: "Allow members to log in or sign up for Plane with their GitHub accounts.",
|
||||
icon: (
|
||||
<img
|
||||
src={resolvedTheme === "dark" ? githubDarkModeImage : githubLightModeImage}
|
||||
height={20}
|
||||
width={20}
|
||||
alt="GitHub Logo"
|
||||
/>
|
||||
),
|
||||
config: <GithubConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
key: "gitlab",
|
||||
name: "GitLab",
|
||||
description: "Allow members to log in or sign up to plane with their GitLab accounts.",
|
||||
icon: <img src={GitlabLogo} height={20} width={20} alt="GitLab Logo" />,
|
||||
config: <GitlabConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
|
||||
import { ToggleSwitch } from "@plane/ui";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
disabled: boolean;
|
||||
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
|
||||
};
|
||||
|
||||
export const PasswordLoginConfiguration: React.FC<Props> = observer((props) => {
|
||||
const { disabled, updateConfig } = props;
|
||||
// store
|
||||
const { formattedConfig } = useInstance();
|
||||
// derived values
|
||||
const enableEmailPassword = formattedConfig?.ENABLE_EMAIL_PASSWORD ?? "";
|
||||
|
||||
return (
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableEmailPassword))}
|
||||
onChange={() => {
|
||||
const newEnableEmailPassword = Boolean(parseInt(enableEmailPassword)) === true ? "0" : "1";
|
||||
updateConfig("ENABLE_EMAIL_PASSWORD", newEnableEmailPassword);
|
||||
}}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user