Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a3950230c | |||
| 37320aa8cf | |||
| 0ab63ecf39 | |||
| 14f9f65a33 | |||
| 75d18adfd3 | |||
| 05a9065b12 | |||
| daf71f81f9 | |||
| cf0e89012c | |||
| 083bd18bc4 | |||
| 0ceb9974f6 | |||
| e0fcc0c876 |
@@ -6,18 +6,17 @@ on:
|
||||
- closed
|
||||
branches:
|
||||
- master
|
||||
- release
|
||||
- preview
|
||||
- qa
|
||||
- develop
|
||||
release:
|
||||
types: [released, prereleased]
|
||||
|
||||
env:
|
||||
TARGET_BRANCH: ${{ github.event.pull_request.base.ref || github.event.release.target_commitish }}
|
||||
TARGET_BRANCH: ${{ github.event.pull_request.base.ref }}
|
||||
|
||||
jobs:
|
||||
branch_build_setup:
|
||||
if: ${{ (github.event_name == 'pull_request' && github.event.action =='closed' && github.event.pull_request.merged == true) || github.event_name == 'release' }}
|
||||
branch_build_and_push:
|
||||
if: ${{ (github.event_name == 'pull_request' && github.event.action =='closed' && github.event.pull_request.merged == true) }}
|
||||
name: Build-Push Web/Space/API/Proxy Docker Image
|
||||
runs-on: ubuntu-20.04
|
||||
|
||||
@@ -60,18 +59,18 @@ jobs:
|
||||
|
||||
branch_build_push_frontend:
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
needs: [branch_build_and_push]
|
||||
env:
|
||||
FRONTEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend-ce:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
FRONTEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend-private:${{ needs.branch_build_and_push.outputs.gh_branch_name }}
|
||||
steps:
|
||||
- name: Set Frontend Docker Tag
|
||||
run: |
|
||||
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend-ce:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend-ce:${{ github.event.release.tag_name }}
|
||||
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend-ce:stable
|
||||
if [ "${{ needs.branch_build_and_push.outputs.gh_branch_name }}" = "master" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend-private:latest"
|
||||
elif [ "${{ needs.branch_build_and_push.outputs.gh_branch_name }}" = "release" ] || [ "${{ needs.branch_build_and_push.outputs.gh_branch_name }}" = "preview" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend-private:preview,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:preview"
|
||||
else
|
||||
TAG=${{ env.FRONTEND_TAG }}
|
||||
TAG=${{ env.FRONTEND_TAG }}"
|
||||
fi
|
||||
echo "FRONTEND_TAG=${TAG}" >> $GITHUB_ENV
|
||||
- name: Set up Docker Buildx
|
||||
@@ -102,18 +101,18 @@ jobs:
|
||||
|
||||
branch_build_push_space:
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
needs: [branch_build_and_push]
|
||||
env:
|
||||
SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space-ce:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space-private:${{ needs.branch_build_and_push.outputs.gh_branch_name }}
|
||||
steps:
|
||||
- name: Set Space Docker Tag
|
||||
run: |
|
||||
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space-ce:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space-ce:${{ github.event.release.tag_name }}
|
||||
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space-ce:stable
|
||||
if [ "${{ needs.branch_build_and_push.outputs.gh_branch_name }}" = "master" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest"
|
||||
elif [ "${{ needs.branch_build_and_push.outputs.gh_branch_name }}" = "release" ] || [ "${{ needs.branch_build_and_push.outputs.gh_branch_name }}" = "preview" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space-private:preview,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:preview"
|
||||
else
|
||||
TAG=${{ env.SPACE_TAG }}
|
||||
TAG=${{ env.SPACE_TAG }}"
|
||||
fi
|
||||
echo "SPACE_TAG=${TAG}" >> $GITHUB_ENV
|
||||
- name: Set up Docker Buildx
|
||||
@@ -144,16 +143,16 @@ jobs:
|
||||
|
||||
branch_build_push_backend:
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
needs: [branch_build_and_push]
|
||||
env:
|
||||
BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend-ce:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend-private:${{ needs.branch_build_and_push.outputs.gh_branch_name }}
|
||||
steps:
|
||||
- name: Set Backend Docker Tag
|
||||
run: |
|
||||
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend-ce:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend-ce:${{ github.event.release.tag_name }}
|
||||
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend-ce:stable
|
||||
if [ "${{ needs.branch_build_and_push.outputs.gh_branch_name }}" = "master" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend-private:latest"
|
||||
elif [ "${{ needs.branch_build_and_push.outputs.gh_branch_name }}" = "release" ] || [ "${{ needs.branch_build_and_push.outputs.gh_branch_name }}" = "preview" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend-private:preview",${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:preview"
|
||||
else
|
||||
TAG=${{ env.BACKEND_TAG }}
|
||||
fi
|
||||
@@ -186,16 +185,16 @@ jobs:
|
||||
|
||||
branch_build_push_proxy:
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
needs: [branch_build_and_push]
|
||||
env:
|
||||
PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy-ce:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy-private:${{ needs.branch_build_and_push.outputs.gh_branch_name }}
|
||||
steps:
|
||||
- name: Set Proxy Docker Tag
|
||||
run: |
|
||||
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy-ce:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy-ce:${{ github.event.release.tag_name }}
|
||||
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy-ce:stable
|
||||
if [ "${{ needs.branch_build_and_push.outputs.gh_branch_name }}" = "master" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy-private:latest"
|
||||
elif [ "${{ needs.branch_build_and_push.outputs.gh_branch_name }}" = "release" ] || [ "${{ needs.branch_build_and_push.outputs.gh_branch_name }}" = "preview" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy-private:preview,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:preview"
|
||||
else
|
||||
TAG=${{ env.PROXY_TAG }}
|
||||
fi
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ 'develop', 'hot-fix', 'stage-release' ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ 'develop' ]
|
||||
schedule:
|
||||
- cron: '53 19 * * 5'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'python', 'javascript' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Use only 'java' to analyze code written in Java, Kotlin or both
|
||||
# Use only 'javascript' to analyze code written in JavaScript, TypeScript or both
|
||||
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
|
||||
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||
|
||||
# - run: |
|
||||
# echo "Run, Build Application using script"
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
@@ -0,0 +1,107 @@
|
||||
name: Update Docker Images for Plane on Release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [released, prereleased]
|
||||
|
||||
jobs:
|
||||
build_push_backend:
|
||||
name: Build and Push Api Server Docker Image
|
||||
runs-on: ubuntu-20.04
|
||||
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3.3.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2.5.0
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release
|
||||
id: metaFrontend
|
||||
uses: docker/metadata-action@v4.3.0
|
||||
with:
|
||||
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release
|
||||
id: metaBackend
|
||||
uses: docker/metadata-action@v4.3.0
|
||||
with:
|
||||
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release
|
||||
id: metaSpace
|
||||
uses: docker/metadata-action@v4.3.0
|
||||
with:
|
||||
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release
|
||||
id: metaProxy
|
||||
uses: docker/metadata-action@v4.3.0
|
||||
with:
|
||||
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
|
||||
- name: Build and Push Frontend to Docker Container Registry
|
||||
uses: docker/build-push-action@v4.0.0
|
||||
with:
|
||||
context: .
|
||||
file: ./web/Dockerfile.web
|
||||
platforms: linux/amd64
|
||||
tags: ${{ steps.metaFrontend.outputs.tags }}
|
||||
push: true
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and Push Backend to Docker Hub
|
||||
uses: docker/build-push-action@v4.0.0
|
||||
with:
|
||||
context: ./apiserver
|
||||
file: ./apiserver/Dockerfile.api
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.metaBackend.outputs.tags }}
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and Push Plane-Deploy to Docker Hub
|
||||
uses: docker/build-push-action@v4.0.0
|
||||
with:
|
||||
context: .
|
||||
file: ./space/Dockerfile.space
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.metaSpace.outputs.tags }}
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and Push Plane-Proxy to Docker Hub
|
||||
uses: docker/build-push-action@v4.0.0
|
||||
with:
|
||||
context: ./nginx
|
||||
file: ./nginx/Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.metaProxy.outputs.tags }}
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -79,6 +79,7 @@ COPY apiserver/manage.py manage.py
|
||||
COPY apiserver/plane plane/
|
||||
COPY apiserver/templates templates/
|
||||
|
||||
COPY apiserver/gunicorn.config.py ./
|
||||
RUN apk --no-cache add "bash~=5.2"
|
||||
COPY apiserver/bin ./bin/
|
||||
|
||||
|
||||
@@ -14,11 +14,6 @@ PGHOST="plane-db"
|
||||
PGDATABASE="plane"
|
||||
DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE}
|
||||
|
||||
# Oauth variables
|
||||
GOOGLE_CLIENT_ID=""
|
||||
GITHUB_CLIENT_ID=""
|
||||
GITHUB_CLIENT_SECRET=""
|
||||
|
||||
# Redis Settings
|
||||
REDIS_HOST="plane-redis"
|
||||
REDIS_PORT="6379"
|
||||
@@ -55,6 +50,7 @@ NGINX_PORT=80
|
||||
# SignUps
|
||||
ENABLE_SIGNUP="1"
|
||||
|
||||
|
||||
# Enable Email/Password Signup
|
||||
ENABLE_EMAIL_PASSWORD="1"
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ COPY manage.py manage.py
|
||||
COPY plane plane/
|
||||
COPY templates templates/
|
||||
COPY package.json package.json
|
||||
COPY gunicorn.config.py ./
|
||||
USER root
|
||||
RUN apk --no-cache add "bash~=5.2"
|
||||
COPY ./bin ./bin/
|
||||
|
||||
@@ -27,4 +27,4 @@ python manage.py configure_instance
|
||||
# Create the default bucket
|
||||
python manage.py create_bucket
|
||||
|
||||
exec gunicorn -w $GUNICORN_WORKERS -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:${PORT:-8000} --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
|
||||
exec gunicorn -w $GUNICORN_WORKERS -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:8000 --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
from psycogreen.gevent import patch_psycopg
|
||||
|
||||
|
||||
def post_fork(server, worker):
|
||||
patch_psycopg()
|
||||
worker.log.info("Made Psycopg2 Green")
|
||||
@@ -30,11 +30,6 @@ class CycleSerializer(BaseSerializer):
|
||||
model = Cycle
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"workspace",
|
||||
"project",
|
||||
"owned_by",
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
from lxml import html
|
||||
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -24,8 +21,7 @@ from plane.db.models import (
|
||||
from .base import BaseSerializer
|
||||
from .cycle import CycleSerializer, CycleLiteSerializer
|
||||
from .module import ModuleSerializer, ModuleLiteSerializer
|
||||
from .user import UserLiteSerializer
|
||||
from .state import StateLiteSerializer
|
||||
|
||||
|
||||
class IssueSerializer(BaseSerializer):
|
||||
assignees = serializers.ListField(
|
||||
@@ -46,6 +42,7 @@ class IssueSerializer(BaseSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Issue
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"workspace",
|
||||
@@ -55,10 +52,6 @@ class IssueSerializer(BaseSerializer):
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
exclude = [
|
||||
"description",
|
||||
"description_stripped",
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
if (
|
||||
@@ -67,15 +60,6 @@ class IssueSerializer(BaseSerializer):
|
||||
and data.get("start_date", None) > data.get("target_date", None)
|
||||
):
|
||||
raise serializers.ValidationError("Start date cannot exceed target date")
|
||||
|
||||
try:
|
||||
if(data.get("description_html", None) is not None):
|
||||
parsed = html.fromstring(data["description_html"])
|
||||
parsed_str = html.tostring(parsed, encoding='unicode')
|
||||
data["description_html"] = parsed_str
|
||||
|
||||
except Exception as e:
|
||||
raise serializers.ValidationError(f"Invalid HTML: {str(e)}")
|
||||
|
||||
# Validate assignees are from project
|
||||
if data.get("assignees", []):
|
||||
@@ -307,6 +291,7 @@ class IssueCommentSerializer(BaseSerializer):
|
||||
|
||||
class Meta:
|
||||
model = IssueComment
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"workspace",
|
||||
@@ -317,21 +302,6 @@ class IssueCommentSerializer(BaseSerializer):
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
exclude = [
|
||||
"comment_stripped",
|
||||
"comment_json",
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
try:
|
||||
if(data.get("comment_html", None) is not None):
|
||||
parsed = html.fromstring(data["comment_html"])
|
||||
parsed_str = html.tostring(parsed, encoding='unicode')
|
||||
data["comment_html"] = parsed_str
|
||||
|
||||
except Exception as e:
|
||||
raise serializers.ValidationError(f"Invalid HTML: {str(e)}")
|
||||
return data
|
||||
|
||||
|
||||
class IssueActivitySerializer(BaseSerializer):
|
||||
@@ -361,23 +331,12 @@ class ModuleIssueSerializer(BaseSerializer):
|
||||
]
|
||||
|
||||
|
||||
class LabelLiteSerializer(BaseSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Label
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"color",
|
||||
]
|
||||
|
||||
|
||||
class IssueExpandSerializer(BaseSerializer):
|
||||
# Serialize the related cycle. It's a OneToOne relation.
|
||||
cycle = CycleLiteSerializer(source="issue_cycle.cycle", read_only=True)
|
||||
|
||||
# Serialize the related module. It's a OneToOne relation.
|
||||
module = ModuleLiteSerializer(source="issue_module.module", read_only=True)
|
||||
labels = LabelLiteSerializer(read_only=True, many=True)
|
||||
assignees = UserLiteSerializer(read_only=True, many=True)
|
||||
state = StateLiteSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Issue
|
||||
@@ -390,4 +349,4 @@ class IssueExpandSerializer(BaseSerializer):
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
]
|
||||
@@ -21,7 +21,6 @@ class ProjectSerializer(BaseSerializer):
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"id",
|
||||
'emoji',
|
||||
"workspace",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
|
||||
@@ -16,11 +16,6 @@ class StateSerializer(BaseSerializer):
|
||||
model = State
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"workspace",
|
||||
"project",
|
||||
]
|
||||
|
||||
@@ -11,6 +11,10 @@ class UserLiteSerializer(BaseSerializer):
|
||||
"first_name",
|
||||
"last_name",
|
||||
"avatar",
|
||||
"is_bot",
|
||||
"display_name",
|
||||
]
|
||||
read_only_fields = fields
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"is_bot",
|
||||
]
|
||||
@@ -103,7 +103,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
|
||||
if inbox is None and not project.inbox_view:
|
||||
return Response(
|
||||
{
|
||||
"error": "Inbox is not enabled for this project enable it through the project's api"
|
||||
"error": "Inbox is not enabled for this project enable it through the project settings"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
@@ -177,7 +177,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
|
||||
if inbox is None and not project.inbox_view:
|
||||
return Response(
|
||||
{
|
||||
"error": "Inbox is not enabled for this project enable it through the project's api"
|
||||
"error": "Inbox is not enabled for this project enable it through the project settings"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
@@ -311,7 +311,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
|
||||
if inbox is None and not project.inbox_view:
|
||||
return Response(
|
||||
{
|
||||
"error": "Inbox is not enabled for this project enable it through the project's api"
|
||||
"error": "Inbox is not enabled for this project enable it through the project settings"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -64,7 +64,7 @@ class StateAPIEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
if state.default:
|
||||
return Response({"error": "Default state cannot be deleted"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response({"error": "Default state cannot be deleted"}, status=False)
|
||||
|
||||
# Check for any issues in the state
|
||||
issue_exist = Issue.issue_objects.filter(state=state_id).exists()
|
||||
|
||||
@@ -103,19 +103,16 @@ class ProjectListSerializer(DynamicBaseSerializer):
|
||||
members = serializers.SerializerMethodField()
|
||||
|
||||
def get_members(self, obj):
|
||||
project_members = getattr(obj, "members_list", None)
|
||||
if project_members is not None:
|
||||
# Filter members by the project ID
|
||||
return [
|
||||
{
|
||||
"id": member.id,
|
||||
"member_id": member.member_id,
|
||||
"member__display_name": member.member.display_name,
|
||||
"member__avatar": member.member.avatar,
|
||||
}
|
||||
for member in project_members
|
||||
]
|
||||
return []
|
||||
project_members = ProjectMember.objects.filter(
|
||||
project_id=obj.id,
|
||||
is_active=True,
|
||||
).values(
|
||||
"id",
|
||||
"member_id",
|
||||
"member__display_name",
|
||||
"member__avatar",
|
||||
)
|
||||
return list(project_members)
|
||||
|
||||
class Meta:
|
||||
model = Project
|
||||
|
||||
@@ -169,8 +169,8 @@ class ChangePasswordSerializer(serializers.Serializer):
|
||||
Serializer for password change endpoint.
|
||||
"""
|
||||
old_password = serializers.CharField(required=True)
|
||||
new_password = serializers.CharField(required=True, min_length=8)
|
||||
confirm_password = serializers.CharField(required=True, min_length=8)
|
||||
new_password = serializers.CharField(required=True)
|
||||
confirm_password = serializers.CharField(required=True)
|
||||
|
||||
def validate(self, data):
|
||||
if data.get("old_password") == data.get("new_password"):
|
||||
@@ -187,7 +187,9 @@ class ChangePasswordSerializer(serializers.Serializer):
|
||||
|
||||
|
||||
class ResetPasswordSerializer(serializers.Serializer):
|
||||
model = User
|
||||
|
||||
"""
|
||||
Serializer for password change endpoint.
|
||||
"""
|
||||
new_password = serializers.CharField(required=True, min_length=8)
|
||||
new_password = serializers.CharField(required=True)
|
||||
|
||||
@@ -7,7 +7,6 @@ from plane.app.views import (
|
||||
# Authentication
|
||||
SignInEndpoint,
|
||||
SignOutEndpoint,
|
||||
MagicGenerateEndpoint,
|
||||
MagicSignInEndpoint,
|
||||
OauthEndpoint,
|
||||
EmailCheckEndpoint,
|
||||
@@ -31,7 +30,6 @@ urlpatterns = [
|
||||
path("sign-in/", SignInEndpoint.as_view(), name="sign-in"),
|
||||
path("sign-out/", SignOutEndpoint.as_view(), name="sign-out"),
|
||||
# magic sign in
|
||||
path("magic-generate/", MagicGenerateEndpoint.as_view(), name="magic-generate"),
|
||||
path("magic-sign-in/", MagicSignInEndpoint.as_view(), name="magic-sign-in"),
|
||||
path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
|
||||
# Password Manipulation
|
||||
|
||||
@@ -87,7 +87,6 @@ from .auth_extended import (
|
||||
ChangePasswordEndpoint,
|
||||
SetUserPasswordEndpoint,
|
||||
EmailCheckEndpoint,
|
||||
MagicGenerateEndpoint,
|
||||
)
|
||||
|
||||
|
||||
@@ -130,6 +129,7 @@ from .page import (
|
||||
PageFavoriteViewSet,
|
||||
PageLogEndpoint,
|
||||
SubPagesEndpoint,
|
||||
CreateIssueFromBlockEndpoint,
|
||||
)
|
||||
|
||||
from .search import GlobalSearchEndpoint, IssueSearchEndpoint
|
||||
|
||||
@@ -37,9 +37,9 @@ from plane.bgtasks.forgot_password_task import forgot_password
|
||||
from plane.license.models import Instance, InstanceConfiguration
|
||||
from plane.settings.redis import redis_instance
|
||||
from plane.bgtasks.magic_link_code_task import magic_link
|
||||
from plane.bgtasks.user_count_task import update_user_instance_user_count
|
||||
from plane.bgtasks.event_tracking_task import auth_events
|
||||
|
||||
|
||||
def get_tokens_for_user(user):
|
||||
refresh = RefreshToken.for_user(user)
|
||||
return (
|
||||
@@ -105,24 +105,17 @@ class ForgotPasswordEndpoint(BaseAPIView):
|
||||
def post(self, request):
|
||||
email = request.data.get("email")
|
||||
|
||||
try:
|
||||
validate_email(email)
|
||||
except ValidationError:
|
||||
return Response(
|
||||
{"error": "Please enter a valid email"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if User.objects.filter(email=email).exists():
|
||||
user = User.objects.get(email=email)
|
||||
uidb64 = urlsafe_base64_encode(smart_bytes(user.id))
|
||||
token = PasswordResetTokenGenerator().make_token(user)
|
||||
|
||||
# Get the user
|
||||
user = User.objects.filter(email=email).first()
|
||||
if user:
|
||||
# Get the reset token for user
|
||||
uidb64, token = generate_password_token(user=user)
|
||||
current_site = request.META.get("HTTP_ORIGIN")
|
||||
# send the forgot password email
|
||||
|
||||
forgot_password.delay(
|
||||
user.first_name, user.email, uidb64, token, current_site
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"message": "Check your email to reset your password"},
|
||||
status=status.HTTP_200_OK,
|
||||
@@ -133,24 +126,18 @@ class ForgotPasswordEndpoint(BaseAPIView):
|
||||
|
||||
|
||||
class ResetPasswordEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
AllowAny,
|
||||
]
|
||||
permission_classes = [AllowAny,]
|
||||
|
||||
def post(self, request, uidb64, token):
|
||||
try:
|
||||
# Decode the id from the uidb64
|
||||
id = smart_str(urlsafe_base64_decode(uidb64))
|
||||
user = User.objects.get(id=id)
|
||||
|
||||
# check if the token is valid for the user
|
||||
if not PasswordResetTokenGenerator().check_token(user, token):
|
||||
return Response(
|
||||
{"error": "Token is invalid"},
|
||||
status=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
# Reset the password
|
||||
serializer = ResetPasswordSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
# set_password also hashes the password that the user will get
|
||||
@@ -158,9 +145,9 @@ class ResetPasswordEndpoint(BaseAPIView):
|
||||
user.is_password_autoset = False
|
||||
user.save()
|
||||
|
||||
# Log the user in
|
||||
# Generate access token for the user
|
||||
access_token, refresh_token = get_tokens_for_user(user)
|
||||
|
||||
data = {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
@@ -179,6 +166,7 @@ class ResetPasswordEndpoint(BaseAPIView):
|
||||
class ChangePasswordEndpoint(BaseAPIView):
|
||||
def post(self, request):
|
||||
serializer = ChangePasswordSerializer(data=request.data)
|
||||
|
||||
user = User.objects.get(pk=request.user.id)
|
||||
if serializer.is_valid():
|
||||
if not user.check_password(serializer.data.get("old_password")):
|
||||
@@ -224,120 +212,35 @@ class SetUserPasswordEndpoint(BaseAPIView):
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class MagicGenerateEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
AllowAny,
|
||||
]
|
||||
|
||||
def post(self, request):
|
||||
email = request.data.get("email", False)
|
||||
|
||||
# Check the instance registration
|
||||
instance = Instance.objects.first()
|
||||
if instance is None or not instance.is_setup_done:
|
||||
return Response(
|
||||
{"error": "Instance is not configured"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if not email:
|
||||
return Response(
|
||||
{"error": "Please provide a valid email address"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Clean up the email
|
||||
email = email.strip().lower()
|
||||
validate_email(email)
|
||||
|
||||
# check if the email exists not
|
||||
if not User.objects.filter(email=email).exists():
|
||||
# Create a user
|
||||
_ = User.objects.create(
|
||||
email=email,
|
||||
username=uuid.uuid4().hex,
|
||||
password=make_password(uuid.uuid4().hex),
|
||||
is_password_autoset=True,
|
||||
)
|
||||
|
||||
## Generate a random token
|
||||
token = (
|
||||
"".join(random.choices(string.ascii_lowercase, k=4))
|
||||
+ "-"
|
||||
+ "".join(random.choices(string.ascii_lowercase, k=4))
|
||||
+ "-"
|
||||
+ "".join(random.choices(string.ascii_lowercase, k=4))
|
||||
)
|
||||
|
||||
ri = redis_instance()
|
||||
|
||||
key = "magic_" + str(email)
|
||||
|
||||
# Check if the key already exists in python
|
||||
if ri.exists(key):
|
||||
data = json.loads(ri.get(key))
|
||||
|
||||
current_attempt = data["current_attempt"] + 1
|
||||
|
||||
if data["current_attempt"] > 2:
|
||||
return Response(
|
||||
{"error": "Max attempts exhausted. Please try again later."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
value = {
|
||||
"current_attempt": current_attempt,
|
||||
"email": email,
|
||||
"token": token,
|
||||
}
|
||||
expiry = 600
|
||||
|
||||
ri.set(key, json.dumps(value), ex=expiry)
|
||||
|
||||
else:
|
||||
value = {"current_attempt": 0, "email": email, "token": token}
|
||||
expiry = 600
|
||||
|
||||
ri.set(key, json.dumps(value), ex=expiry)
|
||||
|
||||
# If the smtp is configured send through here
|
||||
current_site = request.META.get("HTTP_ORIGIN")
|
||||
magic_link.delay(email, key, token, current_site)
|
||||
|
||||
return Response({"key": key}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class EmailCheckEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
AllowAny,
|
||||
]
|
||||
|
||||
def post(self, request):
|
||||
# get the email
|
||||
|
||||
# Check the instance registration
|
||||
instance = Instance.objects.first()
|
||||
if instance is None or not instance.is_setup_done:
|
||||
if instance is None:
|
||||
return Response(
|
||||
{"error": "Instance is not configured"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get the configurations
|
||||
instance_configuration = InstanceConfiguration.objects.values("key", "value")
|
||||
|
||||
email = request.data.get("email", False)
|
||||
type = request.data.get("type", "magic_code")
|
||||
|
||||
if not email:
|
||||
return Response(
|
||||
{"error": "Email is required"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
return Response({"error": "Email is required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# validate the email
|
||||
try:
|
||||
validate_email(email)
|
||||
except ValidationError:
|
||||
return Response(
|
||||
{"error": "Email is not valid"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
return Response({"error": "Email is not valid"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Check if the user exists
|
||||
user = User.objects.filter(email=email).first()
|
||||
@@ -364,7 +267,7 @@ class EmailCheckEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Create the user with default values
|
||||
|
||||
user = User.objects.create(
|
||||
email=email,
|
||||
username=uuid.uuid4().hex,
|
||||
@@ -372,59 +275,71 @@ class EmailCheckEndpoint(BaseAPIView):
|
||||
is_password_autoset=True,
|
||||
)
|
||||
|
||||
# Update instance user count
|
||||
update_user_instance_user_count.delay()
|
||||
|
||||
if not bool(
|
||||
get_configuration_value(
|
||||
# Case when the user selects magic code
|
||||
if type == "magic_code":
|
||||
if not bool(get_configuration_value(
|
||||
instance_configuration,
|
||||
"ENABLE_MAGIC_LINK_LOGIN",
|
||||
os.environ.get("ENABLE_MAGIC_LINK_LOGIN"),
|
||||
),
|
||||
):
|
||||
return Response(
|
||||
{"error": "Magic link sign in is disabled."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Send event
|
||||
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
|
||||
auth_events.delay(
|
||||
user=user.id,
|
||||
email=email,
|
||||
user_agent=request.META.get("HTTP_USER_AGENT"),
|
||||
ip=request.META.get("REMOTE_ADDR"),
|
||||
event_name="SIGN_IN",
|
||||
medium="MAGIC_LINK",
|
||||
first_time=True,
|
||||
)
|
||||
key, token, current_attempt = generate_magic_token(email=email)
|
||||
if not current_attempt:
|
||||
return Response(
|
||||
{"error": "Max attempts exhausted. Please try again later."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# Trigger the email
|
||||
magic_link.delay(email, "magic_" + str(email), token, current_site)
|
||||
return Response(
|
||||
{"is_password_autoset": user.is_password_autoset, "is_existing": False},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
# Existing user
|
||||
else:
|
||||
if user.is_password_autoset:
|
||||
## Generate a random token
|
||||
if not bool(
|
||||
get_configuration_value(
|
||||
instance_configuration,
|
||||
"ENABLE_MAGIC_LINK_LOGIN",
|
||||
os.environ.get("ENABLE_MAGIC_LINK_LOGIN"),
|
||||
),
|
||||
os.environ.get("ENABLE_MAGIC_LINK_LOGIN")),
|
||||
):
|
||||
return Response(
|
||||
{"error": "Magic link sign in is disabled."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
# Send event
|
||||
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
|
||||
auth_events.delay(
|
||||
user=user.id,
|
||||
email=email,
|
||||
user_agent=request.META.get("HTTP_USER_AGENT"),
|
||||
ip=request.META.get("REMOTE_ADDR"),
|
||||
event_name="SIGN_IN",
|
||||
medium="MAGIC_LINK",
|
||||
first_time=True,
|
||||
)
|
||||
key, token, current_attempt = generate_magic_token(email=email)
|
||||
if not current_attempt:
|
||||
return Response({"error": "Max attempts exhausted. Please try again later."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
# Trigger the email
|
||||
magic_link.delay(email, "magic_" + str(email), token, current_site)
|
||||
return Response({"is_password_autoset": user.is_password_autoset}, status=status.HTTP_200_OK)
|
||||
else:
|
||||
# Get the uidb64 and token for the user
|
||||
uidb64, token = generate_password_token(user=user)
|
||||
forgot_password.delay(
|
||||
user.first_name, user.email, uidb64, token, current_site
|
||||
)
|
||||
# Send event
|
||||
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
|
||||
auth_events.delay(
|
||||
user=user.id,
|
||||
email=email,
|
||||
user_agent=request.META.get("HTTP_USER_AGENT"),
|
||||
ip=request.META.get("REMOTE_ADDR"),
|
||||
event_name="SIGN_IN",
|
||||
medium="EMAIL",
|
||||
first_time=True,
|
||||
)
|
||||
# Automatically send the email
|
||||
return Response({"is_password_autoset": user.is_password_autoset}, status=status.HTTP_400_BAD_REQUEST)
|
||||
# Existing user
|
||||
else:
|
||||
if type == "magic_code":
|
||||
## Generate a random token
|
||||
if not bool(get_configuration_value(
|
||||
instance_configuration,
|
||||
"ENABLE_MAGIC_LINK_LOGIN",
|
||||
os.environ.get("ENABLE_MAGIC_LINK_LOGIN")),
|
||||
):
|
||||
return Response(
|
||||
{"error": "Magic link sign in is disabled."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
|
||||
auth_events.delay(
|
||||
user=user.id,
|
||||
@@ -435,24 +350,15 @@ class EmailCheckEndpoint(BaseAPIView):
|
||||
medium="MAGIC_LINK",
|
||||
first_time=False,
|
||||
)
|
||||
|
||||
|
||||
# Generate magic token
|
||||
key, token, current_attempt = generate_magic_token(email=email)
|
||||
if not current_attempt:
|
||||
return Response(
|
||||
{"error": "Max attempts exhausted. Please try again later."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
return Response({"error": "Max attempts exhausted. Please try again later."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Trigger the email
|
||||
magic_link.delay(email, key, token, current_site)
|
||||
return Response(
|
||||
{
|
||||
"is_password_autoset": user.is_password_autoset,
|
||||
"is_existing": True,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
return Response({"is_password_autoset": user.is_password_autoset}, status=status.HTTP_200_OK)
|
||||
else:
|
||||
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
|
||||
auth_events.delay(
|
||||
@@ -464,12 +370,14 @@ class EmailCheckEndpoint(BaseAPIView):
|
||||
medium="EMAIL",
|
||||
first_time=False,
|
||||
)
|
||||
|
||||
# User should enter password to login
|
||||
return Response(
|
||||
{
|
||||
"is_password_autoset": user.is_password_autoset,
|
||||
"is_existing": True,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
if user.is_password_autoset:
|
||||
# send email
|
||||
uidb64, token = generate_password_token(user=user)
|
||||
forgot_password.delay(
|
||||
user.first_name, user.email, uidb64, token, current_site
|
||||
)
|
||||
return Response({"is_password_autoset": user.is_password_autoset}, status=status.HTTP_200_OK)
|
||||
else:
|
||||
# User should enter password to login
|
||||
return Response({"is_password_autoset": user.is_password_autoset}, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Python imports
|
||||
import os
|
||||
import uuid
|
||||
import random
|
||||
import string
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
@@ -15,6 +17,7 @@ from rest_framework.response import Response
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework import status
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from sentry_sdk import capture_message
|
||||
|
||||
# Module imports
|
||||
@@ -30,6 +33,8 @@ from plane.settings.redis import redis_instance
|
||||
from plane.license.models import InstanceConfiguration, Instance
|
||||
from plane.license.utils.instance_value import get_configuration_value
|
||||
from plane.bgtasks.event_tracking_task import auth_events
|
||||
from plane.bgtasks.magic_link_code_task import magic_link
|
||||
from plane.bgtasks.user_count_task import update_user_instance_user_count
|
||||
|
||||
|
||||
def get_tokens_for_user(user):
|
||||
@@ -44,18 +49,11 @@ class SignUpEndpoint(BaseAPIView):
|
||||
permission_classes = (AllowAny,)
|
||||
|
||||
def post(self, request):
|
||||
# Check if the instance configuration is done
|
||||
instance = Instance.objects.first()
|
||||
if instance is None or not instance.is_setup_done:
|
||||
return Response(
|
||||
{"error": "Instance is not configured"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
instance_configuration = InstanceConfiguration.objects.values("key", "value")
|
||||
|
||||
email = request.data.get("email", False)
|
||||
password = request.data.get("password", False)
|
||||
|
||||
## Raise exception if any of the above are missing
|
||||
if not email or not password:
|
||||
return Response(
|
||||
@@ -63,8 +61,8 @@ class SignUpEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Validate the email
|
||||
email = email.strip().lower()
|
||||
|
||||
try:
|
||||
validate_email(email)
|
||||
except ValidationError as e:
|
||||
@@ -73,7 +71,6 @@ class SignUpEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# If the sign up is not enabled and the user does not have invite disallow him from creating the account
|
||||
if (
|
||||
get_configuration_value(
|
||||
instance_configuration,
|
||||
@@ -103,7 +100,6 @@ class SignUpEndpoint(BaseAPIView):
|
||||
user.set_password(password)
|
||||
|
||||
# settings last actives for the user
|
||||
user.is_password_autoset = False
|
||||
user.last_active = timezone.now()
|
||||
user.last_login_time = timezone.now()
|
||||
user.last_login_ip = request.META.get("REMOTE_ADDR")
|
||||
@@ -118,6 +114,9 @@ class SignUpEndpoint(BaseAPIView):
|
||||
"refresh_token": refresh_token,
|
||||
}
|
||||
|
||||
# Update instance user count
|
||||
update_user_instance_user_count.delay()
|
||||
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
@@ -125,14 +124,6 @@ class SignInEndpoint(BaseAPIView):
|
||||
permission_classes = (AllowAny,)
|
||||
|
||||
def post(self, request):
|
||||
# Check if the instance configuration is done
|
||||
instance = Instance.objects.first()
|
||||
if instance is None or not instance.is_setup_done:
|
||||
return Response(
|
||||
{"error": "Instance is not configured"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
email = request.data.get("email", False)
|
||||
password = request.data.get("password", False)
|
||||
|
||||
@@ -143,8 +134,8 @@ class SignInEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Validate email
|
||||
email = email.strip().lower()
|
||||
|
||||
try:
|
||||
validate_email(email)
|
||||
except ValidationError as e:
|
||||
@@ -153,48 +144,33 @@ class SignInEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check if the instance setup is done or not
|
||||
instance = Instance.objects.first()
|
||||
if instance is None or not instance.is_setup_done:
|
||||
return Response(
|
||||
{"error": "Instance is not configured"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get the user
|
||||
user = User.objects.filter(email=email).first()
|
||||
|
||||
# Existing user
|
||||
if user:
|
||||
# Check user password
|
||||
if not user.check_password(password):
|
||||
return Response(
|
||||
{
|
||||
"error": "Sorry, we could not find a user with the provided credentials. Please try again."
|
||||
},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
# User is not present in db
|
||||
if user is None:
|
||||
return Response(
|
||||
{
|
||||
"error": "Sorry, we could not find a user with the provided credentials. Please try again."
|
||||
},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
# Create the user
|
||||
else:
|
||||
# Get the configurations
|
||||
instance_configuration = InstanceConfiguration.objects.values("key", "value")
|
||||
# Create the user
|
||||
if (
|
||||
get_configuration_value(
|
||||
instance_configuration,
|
||||
"ENABLE_SIGNUP",
|
||||
os.environ.get("ENABLE_SIGNUP", "0"),
|
||||
)
|
||||
== "0"
|
||||
and not WorkspaceMemberInvite.objects.filter(
|
||||
email=email,
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "New account creation is disabled. Please contact your site administrator"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
user = User.objects.create(
|
||||
email=email,
|
||||
username=uuid.uuid4().hex,
|
||||
password=make_password(password),
|
||||
is_password_autoset=False,
|
||||
# Check user password
|
||||
if not user.check_password(password):
|
||||
return Response(
|
||||
{
|
||||
"error": "Sorry, we could not find a user with the provided credentials. Please try again."
|
||||
},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
# settings last active for the user
|
||||
@@ -312,7 +288,6 @@ class MagicSignInEndpoint(BaseAPIView):
|
||||
]
|
||||
|
||||
def post(self, request):
|
||||
# Check if the instance configuration is done
|
||||
instance = Instance.objects.first()
|
||||
if instance is None or not instance.is_setup_done:
|
||||
return Response(
|
||||
|
||||
@@ -11,7 +11,7 @@ from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from .base import BaseAPIView
|
||||
from plane.license.models import InstanceConfiguration
|
||||
from plane.license.models import Instance, InstanceConfiguration
|
||||
from plane.license.utils.instance_value import get_configuration_value
|
||||
|
||||
|
||||
@@ -104,6 +104,4 @@ class ConfigurationEndpoint(BaseAPIView):
|
||||
|
||||
data["file_size_limit"] = float(os.environ.get("FILE_SIZE_LIMIT", 5242880))
|
||||
|
||||
data["is_self_managed"] = bool(int(os.environ.get("IS_SELF_MANAGED", "1")))
|
||||
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -32,6 +32,7 @@ from plane.bgtasks.event_tracking_task import auth_events
|
||||
from .base import BaseAPIView
|
||||
from plane.license.models import InstanceConfiguration, Instance
|
||||
from plane.license.utils.instance_value import get_configuration_value
|
||||
from plane.bgtasks.user_count_task import update_user_instance_user_count
|
||||
|
||||
|
||||
def get_tokens_for_user(user):
|
||||
@@ -302,6 +303,14 @@ class OauthEndpoint(BaseAPIView):
|
||||
instance_configuration = InstanceConfiguration.objects.values(
|
||||
"key", "value"
|
||||
)
|
||||
# Check if instance is registered or not
|
||||
instance = Instance.objects.first()
|
||||
if instance is None and not instance.is_setup_done:
|
||||
return Response(
|
||||
{"error": "Instance is not configured"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if (
|
||||
get_configuration_value(
|
||||
instance_configuration,
|
||||
@@ -438,4 +447,6 @@ class OauthEndpoint(BaseAPIView):
|
||||
"refresh_token": refresh_token,
|
||||
}
|
||||
|
||||
# Update the user count
|
||||
update_user_instance_user_count.delay()
|
||||
return Response(data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@@ -22,7 +22,6 @@ from plane.db.models import (
|
||||
IssueAssignee,
|
||||
IssueActivity,
|
||||
PageLog,
|
||||
ProjectMember,
|
||||
)
|
||||
from plane.app.serializers import (
|
||||
PageSerializer,
|
||||
@@ -141,6 +140,12 @@ class PageViewSet(BaseViewSet):
|
||||
pk=page_id, workspace__slug=slug, project_id=project_id
|
||||
).first()
|
||||
|
||||
# only the owner can lock the page
|
||||
if request.user.id != page.owned_by_id:
|
||||
return Response(
|
||||
{"error": "Only the page owner can lock the page"},
|
||||
)
|
||||
|
||||
page.is_locked = True
|
||||
page.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -150,6 +155,12 @@ class PageViewSet(BaseViewSet):
|
||||
pk=page_id, workspace__slug=slug, project_id=project_id
|
||||
).first()
|
||||
|
||||
# only the owner can unlock the page
|
||||
if request.user.id != page.owned_by_id:
|
||||
return Response(
|
||||
{"error": "Only the page owner can unlock the page"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
page.is_locked = False
|
||||
page.save()
|
||||
|
||||
@@ -164,16 +175,10 @@ class PageViewSet(BaseViewSet):
|
||||
def archive(self, request, slug, project_id, page_id):
|
||||
page = Page.objects.get(pk=page_id, workspace__slug=slug, project_id=project_id)
|
||||
|
||||
# only the owner and admin can archive the page
|
||||
if (
|
||||
ProjectMember.objects.filter(
|
||||
project_id=project_id, member=request.user, is_active=True, role__gt=20
|
||||
).exists()
|
||||
or request.user.id != page.owned_by_id
|
||||
):
|
||||
if page.owned_by_id != request.user.id:
|
||||
return Response(
|
||||
{"error": "Only the owner and admin can archive the page"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
{"error": "Only the owner of the page can archive a page"},
|
||||
status=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
|
||||
unarchive_archive_page_and_descendants(page_id, datetime.now())
|
||||
@@ -183,15 +188,9 @@ class PageViewSet(BaseViewSet):
|
||||
def unarchive(self, request, slug, project_id, page_id):
|
||||
page = Page.objects.get(pk=page_id, workspace__slug=slug, project_id=project_id)
|
||||
|
||||
# only the owner and admin can un archive the page
|
||||
if (
|
||||
ProjectMember.objects.filter(
|
||||
project_id=project_id, member=request.user, is_active=True, role__gt=20
|
||||
).exists()
|
||||
or request.user.id != page.owned_by_id
|
||||
):
|
||||
if page.owned_by_id != request.user.id:
|
||||
return Response(
|
||||
{"error": "Only the owner and admin can un archive the page"},
|
||||
{"error": "Only the owner of the page can unarchive a page"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -217,18 +216,6 @@ class PageViewSet(BaseViewSet):
|
||||
def destroy(self, request, slug, project_id, pk):
|
||||
page = Page.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
|
||||
|
||||
# only the owner and admin can delete the page
|
||||
if (
|
||||
ProjectMember.objects.filter(
|
||||
project_id=project_id, member=request.user, is_active=True, role__gt=20
|
||||
).exists()
|
||||
or request.user.id != page.owned_by_id
|
||||
):
|
||||
return Response(
|
||||
{"error": "Only the owner and admin can delete the page"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if page.archived_at is None:
|
||||
return Response(
|
||||
{"error": "The page should be archived before deleting"},
|
||||
@@ -240,6 +227,7 @@ class PageViewSet(BaseViewSet):
|
||||
parent_id=pk, project_id=project_id, workspace__slug=slug
|
||||
).update(parent=None)
|
||||
|
||||
|
||||
page.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -322,6 +310,36 @@ class PageLogEndpoint(BaseAPIView):
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class CreateIssueFromBlockEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def post(self, request, slug, project_id, page_id):
|
||||
page = Page.objects.get(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
pk=page_id,
|
||||
)
|
||||
issue = Issue.objects.create(
|
||||
name=request.data.get("name"),
|
||||
project_id=project_id,
|
||||
)
|
||||
_ = IssueAssignee.objects.create(
|
||||
issue=issue, assignee=request.user, project_id=project_id
|
||||
)
|
||||
|
||||
_ = IssueActivity.objects.create(
|
||||
issue=issue,
|
||||
actor=request.user,
|
||||
project_id=project_id,
|
||||
comment=f"created the issue from {page.name} block",
|
||||
verb="created",
|
||||
)
|
||||
|
||||
return Response(IssueLiteSerializer(issue).data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class SubPagesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
|
||||
@@ -165,7 +165,6 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
).select_related("member"),
|
||||
to_attr='members_list'
|
||||
)
|
||||
)
|
||||
.order_by("sort_order", "name")
|
||||
|
||||
@@ -77,7 +77,7 @@ class StateViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
if state.default:
|
||||
return Response({"error": "Default state cannot be deleted"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response({"error": "Default state cannot be deleted"}, status=False)
|
||||
|
||||
# Check for any issues in the state
|
||||
issue_exist = Issue.issue_objects.filter(state=pk).exists()
|
||||
|
||||
@@ -73,7 +73,8 @@ from plane.app.permissions import (
|
||||
)
|
||||
from plane.bgtasks.workspace_invitation_task import workspace_invitation
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.bgtasks.event_tracking_task import workspace_invite_event
|
||||
from plane.utils.grouper import group_results
|
||||
|
||||
|
||||
class WorkSpaceViewSet(BaseViewSet):
|
||||
model = Workspace
|
||||
@@ -406,17 +407,6 @@ class WorkspaceJoinEndpoint(BaseAPIView):
|
||||
|
||||
# Delete the invitation
|
||||
workspace_invite.delete()
|
||||
|
||||
# Send event
|
||||
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
|
||||
workspace_invite_event.delay(
|
||||
user=user.id if user is not None else None,
|
||||
email=email,
|
||||
user_agent=request.META.get("HTTP_USER_AGENT"),
|
||||
ip=request.META.get("REMOTE_ADDR"),
|
||||
event_name="MEMBER_ACCEPTED",
|
||||
accepted_from="EMAIL",
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"message": "Workspace Invitation Accepted"},
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
# Python imports
|
||||
import csv
|
||||
import io
|
||||
import requests
|
||||
import json
|
||||
import os
|
||||
|
||||
# Django imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
@@ -18,8 +17,8 @@ from sentry_sdk import capture_exception
|
||||
from plane.db.models import Issue
|
||||
from plane.utils.analytics_plot import build_graph_plot
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.license.models import InstanceConfiguration, Instance
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.license.models import InstanceConfiguration
|
||||
from plane.license.utils.instance_value import get_configuration_value
|
||||
|
||||
row_mapping = {
|
||||
"state__name": "State",
|
||||
@@ -44,7 +43,7 @@ CYCLE_ID = "issue_cycle__cycle_id"
|
||||
MODULE_ID = "issue_module__module_id"
|
||||
|
||||
|
||||
def send_export_email(email, slug, csv_buffer, rows):
|
||||
def send_export_email(email, slug, csv_buffer):
|
||||
"""Helper function to send export email."""
|
||||
subject = "Your Export is ready"
|
||||
html_content = render_to_string("emails/exports/analytics.html", {})
|
||||
@@ -56,34 +55,47 @@ def send_export_email(email, slug, csv_buffer, rows):
|
||||
instance_configuration = InstanceConfiguration.objects.filter(
|
||||
key__startswith="EMAIL_"
|
||||
).values("key", "value")
|
||||
|
||||
(
|
||||
EMAIL_HOST,
|
||||
EMAIL_HOST_USER,
|
||||
EMAIL_HOST_PASSWORD,
|
||||
EMAIL_PORT,
|
||||
EMAIL_USE_TLS,
|
||||
EMAIL_FROM,
|
||||
) = get_email_configuration(instance_configuration=instance_configuration)
|
||||
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
port=int(EMAIL_PORT),
|
||||
username=EMAIL_HOST_USER,
|
||||
password=EMAIL_HOST_PASSWORD,
|
||||
use_tls=bool(EMAIL_USE_TLS),
|
||||
host=get_configuration_value(
|
||||
instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST")
|
||||
),
|
||||
port=int(
|
||||
get_configuration_value(
|
||||
instance_configuration, "EMAIL_PORT", os.environ.get("EMAIL_PORT")
|
||||
)
|
||||
),
|
||||
username=get_configuration_value(
|
||||
instance_configuration,
|
||||
"EMAIL_HOST_USER",
|
||||
os.environ.get("EMAIL_HOST_USER"),
|
||||
),
|
||||
password=get_configuration_value(
|
||||
instance_configuration,
|
||||
"EMAIL_HOST_PASSWORD",
|
||||
os.environ.get("EMAIL_HOST_PASSWORD"),
|
||||
),
|
||||
use_tls=bool(
|
||||
get_configuration_value(
|
||||
instance_configuration,
|
||||
"EMAIL_USE_TLS",
|
||||
os.environ.get("EMAIL_USE_TLS", "1"),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_content,
|
||||
from_email=EMAIL_FROM,
|
||||
from_email=get_configuration_value(
|
||||
instance_configuration,
|
||||
"EMAIL_FROM",
|
||||
os.environ.get("EMAIL_FROM", "Team Plane <team@mailer.plane.so>"),
|
||||
),
|
||||
to=[email],
|
||||
connection=connection,
|
||||
)
|
||||
msg.attach(f"{slug}-analytics.csv", csv_buffer.getvalue())
|
||||
msg.send(fail_silently=False)
|
||||
return
|
||||
|
||||
|
||||
def get_assignee_details(slug, filters):
|
||||
@@ -451,11 +463,8 @@ def analytic_export_task(email, data, slug):
|
||||
)
|
||||
|
||||
csv_buffer = generate_csv_from_rows(rows)
|
||||
send_export_email(email, slug, csv_buffer, rows)
|
||||
return
|
||||
send_export_email(email, slug, csv_buffer)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return
|
||||
|
||||
@@ -26,25 +26,5 @@ def auth_events(user, email, user_agent, ip, event_name, medium, first_time):
|
||||
"first_time": first_time
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
|
||||
@shared_task
|
||||
def workspace_invite_event(user, email, user_agent, ip, event_name, accepted_from):
|
||||
try:
|
||||
posthog = Posthog(settings.POSTHOG_API_KEY, host=settings.POSTHOG_HOST)
|
||||
posthog.capture(
|
||||
email,
|
||||
event=event_name,
|
||||
properties={
|
||||
"event_id": uuid.uuid4().hex,
|
||||
"user": {"email": email, "id": str(user)},
|
||||
"device_ctx": {
|
||||
"ip": ip,
|
||||
"user_agent": user_agent,
|
||||
},
|
||||
"accepted_from": accepted_from
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
@@ -39,6 +39,35 @@ def forgot_password(first_name, email, uidb64, token, current_site):
|
||||
EMAIL_FROM,
|
||||
) = get_email_configuration(instance_configuration=instance_configuration)
|
||||
|
||||
# Send the email if the users don't have smtp configured
|
||||
if not EMAIL_HOST or not EMAIL_HOST_USER or not EMAIL_HOST_PASSWORD:
|
||||
# Check the instance registration
|
||||
instance = Instance.objects.first()
|
||||
|
||||
# send the emails through control center
|
||||
license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL", False)
|
||||
|
||||
# headers
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"x-instance-id": instance.instance_id,
|
||||
"x-api-key": instance.api_key,
|
||||
}
|
||||
|
||||
payload = {
|
||||
"abs_url": abs_url,
|
||||
"first_name": first_name,
|
||||
"email": email,
|
||||
}
|
||||
|
||||
_ = requests.post(
|
||||
f"{license_engine_base_url}/api/instances/users/forgot-password/",
|
||||
headers=headers,
|
||||
data=json.dumps(payload),
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
subject = "A new password to your Plane account has been requested"
|
||||
|
||||
context = {
|
||||
|
||||
@@ -26,6 +26,7 @@ from plane.db.models import (
|
||||
IssueProperty,
|
||||
)
|
||||
from plane.bgtasks.user_welcome_task import send_welcome_slack
|
||||
from plane.bgtasks.user_count_task import update_user_instance_user_count
|
||||
|
||||
|
||||
@shared_task
|
||||
@@ -120,6 +121,9 @@ def service_importer(service, importer_id):
|
||||
batch_size=100,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
# Update instance user count
|
||||
update_user_instance_user_count.delay()
|
||||
|
||||
# Check if sync config is on for github importers
|
||||
if service == "github" and importer.config.get("sync", False):
|
||||
|
||||
@@ -21,6 +21,7 @@ from plane.license.utils.instance_value import get_email_configuration
|
||||
@shared_task
|
||||
def magic_link(email, key, token, current_site):
|
||||
try:
|
||||
|
||||
instance_configuration = InstanceConfiguration.objects.filter(
|
||||
key__startswith="EMAIL_"
|
||||
).values("key", "value")
|
||||
@@ -34,6 +35,33 @@ def magic_link(email, key, token, current_site):
|
||||
EMAIL_FROM,
|
||||
) = get_email_configuration(instance_configuration=instance_configuration)
|
||||
|
||||
# Send the email if the users don't have smtp configured
|
||||
if not EMAIL_HOST or not EMAIL_HOST_USER or not EMAIL_HOST_PASSWORD:
|
||||
# Check the instance registration
|
||||
instance = Instance.objects.first()
|
||||
|
||||
# send the emails through control center
|
||||
license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL", False)
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"x-instance-id": instance.instance_id,
|
||||
"x-api-key": instance.api_key,
|
||||
}
|
||||
|
||||
payload = {
|
||||
"token": token,
|
||||
"email": email,
|
||||
}
|
||||
|
||||
_ = requests.post(
|
||||
f"{license_engine_base_url}/api/instances/users/magic-code/",
|
||||
headers=headers,
|
||||
data=json.dumps(payload),
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
# Send the mail
|
||||
subject = f"Your unique Plane login code is {token}"
|
||||
context = {"code": token, "email": email}
|
||||
|
||||
@@ -14,7 +14,7 @@ from sentry_sdk import capture_exception
|
||||
# Module imports
|
||||
from plane.db.models import Project, User, ProjectMemberInvite
|
||||
from plane.license.models import InstanceConfiguration
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.license.utils.instance_value import get_configuration_value
|
||||
|
||||
@shared_task
|
||||
def project_invitation(email, project_id, token, current_site, invitor):
|
||||
@@ -48,27 +48,42 @@ def project_invitation(email, project_id, token, current_site, invitor):
|
||||
|
||||
# Configure email connection from the database
|
||||
instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value")
|
||||
(
|
||||
EMAIL_HOST,
|
||||
EMAIL_HOST_USER,
|
||||
EMAIL_HOST_PASSWORD,
|
||||
EMAIL_PORT,
|
||||
EMAIL_USE_TLS,
|
||||
EMAIL_FROM,
|
||||
) = get_email_configuration(instance_configuration=instance_configuration)
|
||||
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
port=int(EMAIL_PORT),
|
||||
username=EMAIL_HOST_USER,
|
||||
password=EMAIL_HOST_PASSWORD,
|
||||
use_tls=bool(EMAIL_USE_TLS),
|
||||
host=get_configuration_value(
|
||||
instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST")
|
||||
),
|
||||
port=int(
|
||||
get_configuration_value(
|
||||
instance_configuration, "EMAIL_PORT", os.environ.get("EMAIL_PORT")
|
||||
)
|
||||
),
|
||||
username=get_configuration_value(
|
||||
instance_configuration,
|
||||
"EMAIL_HOST_USER",
|
||||
os.environ.get("EMAIL_HOST_USER"),
|
||||
),
|
||||
password=get_configuration_value(
|
||||
instance_configuration,
|
||||
"EMAIL_HOST_PASSWORD",
|
||||
os.environ.get("EMAIL_HOST_PASSWORD"),
|
||||
),
|
||||
use_tls=bool(
|
||||
get_configuration_value(
|
||||
instance_configuration,
|
||||
"EMAIL_USE_TLS",
|
||||
os.environ.get("EMAIL_USE_TLS", "1"),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_content,
|
||||
from_email=EMAIL_FROM,
|
||||
from_email=get_configuration_value(
|
||||
instance_configuration,
|
||||
"EMAIL_FROM",
|
||||
os.environ.get("EMAIL_FROM", "Team Plane <team@mailer.plane.so>"),
|
||||
),
|
||||
to=[email],
|
||||
connection=connection,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Python imports
|
||||
import json
|
||||
import requests
|
||||
import os
|
||||
|
||||
# django imports
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import User
|
||||
from plane.license.models import Instance
|
||||
|
||||
@shared_task
|
||||
def update_user_instance_user_count():
|
||||
try:
|
||||
instance_users = User.objects.filter(is_bot=False).count()
|
||||
instance = Instance.objects.update(user_count=instance_users)
|
||||
|
||||
# Update the count in the license engine
|
||||
payload = {
|
||||
"user_count": User.objects.count(),
|
||||
}
|
||||
|
||||
# Save the user in control center
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"x-instance-id": instance.instance_id,
|
||||
"x-api-key": instance.api_key,
|
||||
}
|
||||
|
||||
license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL")
|
||||
if not license_engine_base_url:
|
||||
raise Exception("License Engine base url is required")
|
||||
|
||||
# Update the license engine
|
||||
_ = requests.post(
|
||||
f"{license_engine_base_url}/api/instances/",
|
||||
headers=headers,
|
||||
data=json.dumps(payload),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
@@ -109,7 +109,7 @@ def webhook_task(self, webhook, slug, event, event_data, action):
|
||||
if webhook.secret_key:
|
||||
hmac_signature = hmac.new(
|
||||
webhook.secret_key.encode("utf-8"),
|
||||
json.dumps(payload).encode("utf-8"),
|
||||
json.dumps(payload, sort_keys=True).encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
)
|
||||
signature = hmac_signature.hexdigest()
|
||||
|
||||
@@ -50,6 +50,36 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
|
||||
EMAIL_FROM,
|
||||
) = get_email_configuration(instance_configuration=instance_configuration)
|
||||
|
||||
# Send the email if the users don't have smtp configured
|
||||
if not EMAIL_HOST or not EMAIL_HOST_USER or not EMAIL_HOST_PASSWORD:
|
||||
# Check the instance registration
|
||||
instance = Instance.objects.first()
|
||||
|
||||
# send the emails through control center
|
||||
license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL", False)
|
||||
if not license_engine_base_url:
|
||||
raise Exception("License engine base url is required")
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"x-instance-id": instance.instance_id,
|
||||
"x-api-key": instance.api_key,
|
||||
}
|
||||
|
||||
payload = {
|
||||
"user": user.first_name or user.display_name or user.email,
|
||||
"workspace_name": workspace.name,
|
||||
"invitation_url": abs_url,
|
||||
"email": email,
|
||||
}
|
||||
_ = requests.post(
|
||||
f"{license_engine_base_url}/api/instances/users/workspace-invitation/",
|
||||
headers=headers,
|
||||
data=json.dumps(payload),
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
# Subject of the email
|
||||
subject = f"{user.first_name or user.display_name or user.email} has invited you to join them in {workspace.name} on Plane"
|
||||
|
||||
|
||||
@@ -28,13 +28,9 @@ app.conf.beat_schedule = {
|
||||
"task": "plane.bgtasks.file_asset_task.delete_file_asset",
|
||||
"schedule": crontab(hour=0, minute=0),
|
||||
},
|
||||
"check-instance-verification": {
|
||||
"task": "plane.license.bgtasks.instance_verification_task.instance_verification_task",
|
||||
"schedule": crontab(minute=0, hour='*/4'),
|
||||
},
|
||||
}
|
||||
|
||||
# Load task modules from all registered Django app configs.
|
||||
app.autodiscover_tasks()
|
||||
|
||||
app.conf.beat_scheduler = "django_celery_beat.schedulers.DatabaseScheduler"
|
||||
app.conf.beat_scheduler = 'django_celery_beat.schedulers.DatabaseScheduler'
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
# Python imports
|
||||
import getpass
|
||||
|
||||
# Django imports
|
||||
from django.core.management import BaseCommand
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import User
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Reset password of the user with the given email"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
# Positional argument
|
||||
parser.add_argument("email", type=str, help="user email")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# get the user email from console
|
||||
email = options.get("email", False)
|
||||
|
||||
# raise error if email is not present
|
||||
if not email:
|
||||
self.stderr.write("Error: Email is required")
|
||||
return
|
||||
|
||||
# filter the user
|
||||
user = User.objects.filter(email=email).first()
|
||||
|
||||
# Raise error if the user is not present
|
||||
if not user:
|
||||
self.stderr.write(f"Error: User with {email} does not exists")
|
||||
return
|
||||
|
||||
# get password for the user
|
||||
password = getpass.getpass("Password: ")
|
||||
confirm_password = getpass.getpass("Password (again): ")
|
||||
|
||||
# If the passwords doesn't match raise error
|
||||
if password != confirm_password:
|
||||
self.stderr.write("Error: Your passwords didn't match.")
|
||||
return
|
||||
|
||||
# Blank passwords should not be allowed
|
||||
if password.strip() == "":
|
||||
self.stderr.write("Error: Blank passwords aren't allowed.")
|
||||
return
|
||||
|
||||
# Set user password
|
||||
user.set_password(password)
|
||||
user.is_password_autoset = False
|
||||
user.save()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"User password updated succesfully"))
|
||||
@@ -43,7 +43,7 @@ class InstanceConfigurationSerializer(BaseSerializer):
|
||||
def to_representation(self, instance):
|
||||
data = super().to_representation(instance)
|
||||
# Decrypt secrets value
|
||||
if instance.is_encrypted and instance.value is not None:
|
||||
if instance.key in ["OPENAI_API_KEY", "GITHUB_CLIENT_SECRET", "EMAIL_HOST_PASSWORD", "UNSPLASH_ACESS_KEY"] and instance.value is not None:
|
||||
data["value"] = decrypt_data(instance.value)
|
||||
|
||||
return data
|
||||
return data
|
||||
@@ -2,6 +2,8 @@ from .instance import (
|
||||
InstanceEndpoint,
|
||||
InstanceAdminEndpoint,
|
||||
InstanceConfigurationEndpoint,
|
||||
InstanceAdminSignInEndpoint,
|
||||
AdminSetupMagicSignInEndpoint,
|
||||
SignUpScreenVisitedEndpoint,
|
||||
AdminMagicSignInGenerateEndpoint,
|
||||
AdminSetUserPasswordEndpoint,
|
||||
)
|
||||
|
||||
@@ -11,7 +11,6 @@ from django.utils import timezone
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.core.validators import validate_email
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
@@ -27,11 +26,15 @@ from plane.license.api.serializers import (
|
||||
InstanceAdminSerializer,
|
||||
InstanceConfigurationSerializer,
|
||||
)
|
||||
from plane.app.serializers import UserSerializer
|
||||
from plane.license.api.permissions import (
|
||||
InstanceAdminPermission,
|
||||
)
|
||||
from plane.db.models import User
|
||||
from plane.license.utils.encryption import encrypt_data
|
||||
from plane.settings.redis import redis_instance
|
||||
from plane.bgtasks.magic_link_code_task import magic_link
|
||||
from plane.license.utils.instance_value import get_configuration_value
|
||||
|
||||
|
||||
class InstanceEndpoint(BaseAPIView):
|
||||
@@ -44,6 +47,69 @@ class InstanceEndpoint(BaseAPIView):
|
||||
AllowAny(),
|
||||
]
|
||||
|
||||
def post(self, request):
|
||||
# Check if the instance is registered
|
||||
instance = Instance.objects.first()
|
||||
|
||||
# If instance is None then register this instance
|
||||
if instance is None:
|
||||
with open("package.json", "r") as file:
|
||||
# Load JSON content from the file
|
||||
data = json.load(file)
|
||||
|
||||
license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL")
|
||||
|
||||
if not license_engine_base_url:
|
||||
raise Response(
|
||||
{"error": "LICENSE_ENGINE_BASE_URL is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
payload = {
|
||||
"instance_key": os.environ.get("INSTANCE_KEY"),
|
||||
"version": data.get("version", 0.1),
|
||||
"machine_signature": os.environ.get("MACHINE_SIGNATURE"),
|
||||
"user_count": User.objects.filter(is_bot=False).count(),
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
f"{license_engine_base_url}/api/instances/",
|
||||
headers=headers,
|
||||
data=json.dumps(payload),
|
||||
)
|
||||
|
||||
if response.status_code == 201:
|
||||
data = response.json()
|
||||
# Create instance
|
||||
instance = Instance.objects.create(
|
||||
instance_name="Plane Free",
|
||||
instance_id=data.get("id"),
|
||||
license_key=data.get("license_key"),
|
||||
api_key=data.get("api_key"),
|
||||
version=data.get("version"),
|
||||
last_checked_at=timezone.now(),
|
||||
user_count=data.get("user_count", 0),
|
||||
)
|
||||
|
||||
serializer = InstanceSerializer(instance)
|
||||
data = serializer.data
|
||||
data["is_activated"] = True
|
||||
return Response(
|
||||
data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
return Response(
|
||||
{"error": "Instance could not be registered"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
else:
|
||||
return Response(
|
||||
{"message": "Instance already registered"},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def get(self, request):
|
||||
instance = Instance.objects.first()
|
||||
# get the instance
|
||||
@@ -136,7 +202,12 @@ class InstanceConfigurationEndpoint(BaseAPIView):
|
||||
bulk_configurations = []
|
||||
for configuration in configurations:
|
||||
value = request.data.get(configuration.key, configuration.value)
|
||||
if configuration.is_encrypted:
|
||||
if value is not None and configuration.key in [
|
||||
"OPENAI_API_KEY",
|
||||
"GITHUB_CLIENT_SECRET",
|
||||
"EMAIL_HOST_PASSWORD",
|
||||
"UNSPLASH_ACESS_KEY",
|
||||
]:
|
||||
configuration.value = encrypt_data(value)
|
||||
else:
|
||||
configuration.value = value
|
||||
@@ -158,13 +229,15 @@ def get_tokens_for_user(user):
|
||||
)
|
||||
|
||||
|
||||
class InstanceAdminSignInEndpoint(BaseAPIView):
|
||||
class AdminMagicSignInGenerateEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
AllowAny,
|
||||
]
|
||||
|
||||
def post(self, request):
|
||||
# Check instance first
|
||||
email = request.data.get("email", False)
|
||||
|
||||
# Check the instance registration
|
||||
instance = Instance.objects.first()
|
||||
if instance is None:
|
||||
return Response(
|
||||
@@ -172,63 +245,201 @@ class InstanceAdminSignInEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# check if the instance is already activated
|
||||
if InstanceAdmin.objects.first():
|
||||
return Response(
|
||||
{"error": "Admin for this instance is already registered"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get the email and password from all the user
|
||||
email = request.data.get("email", False)
|
||||
password = request.data.get("password", False)
|
||||
|
||||
# return error if the email and password is not present
|
||||
if not email or not password:
|
||||
if not email:
|
||||
return Response(
|
||||
{"error": "Email and password are required"},
|
||||
{"error": "Please provide a valid email address"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Validate the email
|
||||
# Clean up
|
||||
email = email.strip().lower()
|
||||
try:
|
||||
validate_email(email)
|
||||
except ValidationError as e:
|
||||
return Response(
|
||||
{"error": "Please provide a valid email address."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
validate_email(email)
|
||||
|
||||
# Check if already a user exists or not
|
||||
user = User.objects.filter(email=email).first()
|
||||
|
||||
# Existing user
|
||||
if user:
|
||||
# Check user password
|
||||
if not user.check_password(password):
|
||||
return Response(
|
||||
{
|
||||
"error": "Sorry, we could not find a user with the provided credentials. Please try again."
|
||||
},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
else:
|
||||
user = User.objects.create(
|
||||
# check if the email exists
|
||||
if not User.objects.filter(email=email).exists():
|
||||
# Create a user
|
||||
_ = User.objects.create(
|
||||
email=email,
|
||||
username=uuid.uuid4().hex,
|
||||
password=make_password(password),
|
||||
is_password_autoset=False,
|
||||
password=make_password(uuid.uuid4().hex),
|
||||
is_password_autoset=True,
|
||||
)
|
||||
|
||||
# settings last active for the user
|
||||
user.is_active = True
|
||||
user.last_active = timezone.now()
|
||||
user.last_login_time = timezone.now()
|
||||
user.last_login_ip = request.META.get("REMOTE_ADDR")
|
||||
user.last_login_uagent = request.META.get("HTTP_USER_AGENT")
|
||||
user.token_updated_at = timezone.now()
|
||||
user.save()
|
||||
## Generate a random token
|
||||
token = (
|
||||
"".join(random.choices(string.ascii_lowercase, k=4))
|
||||
+ "-"
|
||||
+ "".join(random.choices(string.ascii_lowercase, k=4))
|
||||
+ "-"
|
||||
+ "".join(random.choices(string.ascii_lowercase, k=4))
|
||||
)
|
||||
|
||||
ri = redis_instance()
|
||||
|
||||
key = "magic_" + str(email)
|
||||
|
||||
# Check if the key already exists in python
|
||||
if ri.exists(key):
|
||||
data = json.loads(ri.get(key))
|
||||
|
||||
current_attempt = data["current_attempt"] + 1
|
||||
|
||||
if data["current_attempt"] > 2:
|
||||
return Response(
|
||||
{"error": "Max attempts exhausted. Please try again later."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
value = {
|
||||
"current_attempt": current_attempt,
|
||||
"email": email,
|
||||
"token": token,
|
||||
}
|
||||
expiry = 600
|
||||
|
||||
ri.set(key, json.dumps(value), ex=expiry)
|
||||
|
||||
else:
|
||||
value = {"current_attempt": 0, "email": email, "token": token}
|
||||
expiry = 600
|
||||
|
||||
ri.set(key, json.dumps(value), ex=expiry)
|
||||
|
||||
# If the smtp is configured send through here
|
||||
current_site = request.META.get("HTTP_ORIGIN")
|
||||
magic_link.delay(email, key, token, current_site)
|
||||
|
||||
return Response({"key": key}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class AdminSetupMagicSignInEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
AllowAny,
|
||||
]
|
||||
|
||||
def post(self, request):
|
||||
user_token = request.data.get("token", "").strip()
|
||||
key = request.data.get("key", "").strip().lower()
|
||||
|
||||
if not key or user_token == "":
|
||||
return Response(
|
||||
{"error": "User token and key are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if InstanceAdmin.objects.first():
|
||||
return Response(
|
||||
{"error": "Admin for this instance is already registered"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
ri = redis_instance()
|
||||
|
||||
if ri.exists(key):
|
||||
data = json.loads(ri.get(key))
|
||||
|
||||
token = data["token"]
|
||||
email = data["email"]
|
||||
|
||||
if str(token) == str(user_token):
|
||||
# get the user
|
||||
user = User.objects.get(email=email)
|
||||
# get the email
|
||||
user.is_active = True
|
||||
user.is_email_verified = True
|
||||
user.last_active = timezone.now()
|
||||
user.last_login_time = timezone.now()
|
||||
user.last_login_ip = request.META.get("REMOTE_ADDR")
|
||||
user.last_login_uagent = request.META.get("HTTP_USER_AGENT")
|
||||
user.token_updated_at = timezone.now()
|
||||
user.save()
|
||||
|
||||
access_token, refresh_token = get_tokens_for_user(user)
|
||||
data = {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
}
|
||||
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
else:
|
||||
return Response(
|
||||
{"error": "Your login code was incorrect. Please try again."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
else:
|
||||
return Response(
|
||||
{"error": "The magic code/link has expired please try again"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class AdminSetUserPasswordEndpoint(BaseAPIView):
|
||||
def post(self, request):
|
||||
user = User.objects.get(pk=request.user.id)
|
||||
password = request.data.get("password", False)
|
||||
|
||||
# If the user password is not autoset then return error
|
||||
if not user.is_password_autoset:
|
||||
return Response(
|
||||
{
|
||||
"error": "Your password is already set please change your password from profile"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check password validation
|
||||
if not password and len(str(password)) < 8:
|
||||
return Response(
|
||||
{"error": "Password is not valid"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
instance = Instance.objects.first()
|
||||
if instance is None:
|
||||
return Response(
|
||||
{"error": "Instance is not configured"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL", False)
|
||||
if not license_engine_base_url:
|
||||
return Response(
|
||||
{"error": "License engine base url is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Save the user in control center
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"x-instance-id": instance.instance_id,
|
||||
"x-api-key": instance.api_key,
|
||||
}
|
||||
_ = requests.patch(
|
||||
f"{license_engine_base_url}/api/instances/",
|
||||
headers=headers,
|
||||
data=json.dumps({"is_setup_done": True}),
|
||||
)
|
||||
|
||||
# Also register the user as admin
|
||||
_ = requests.post(
|
||||
f"{license_engine_base_url}/api/instances/users/register/",
|
||||
headers=headers,
|
||||
data=json.dumps(
|
||||
{
|
||||
"email": str(user.email),
|
||||
"signup_mode": "MAGIC_CODE",
|
||||
"is_admin": True,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
# Register the user as an instance admin
|
||||
_ = InstanceAdmin.objects.create(
|
||||
@@ -239,13 +450,12 @@ class InstanceAdminSignInEndpoint(BaseAPIView):
|
||||
instance.is_setup_done = True
|
||||
instance.save()
|
||||
|
||||
# get tokens for user
|
||||
access_token, refresh_token = get_tokens_for_user(user)
|
||||
data = {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
}
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
# Set the user password
|
||||
user.set_password(password)
|
||||
user.is_password_autoset = False
|
||||
user.save()
|
||||
serializer = UserSerializer(user)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class SignUpScreenVisitedEndpoint(BaseAPIView):
|
||||
@@ -255,11 +465,31 @@ class SignUpScreenVisitedEndpoint(BaseAPIView):
|
||||
|
||||
def post(self, request):
|
||||
instance = Instance.objects.first()
|
||||
|
||||
if instance is None:
|
||||
return Response(
|
||||
{"error": "Instance is not configured"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
instance.is_signup_screen_visited = True
|
||||
instance.save()
|
||||
|
||||
license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL", False)
|
||||
|
||||
if not license_engine_base_url:
|
||||
return Response(
|
||||
{"error": "License engine base url is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"x-instance-id": instance.instance_id,
|
||||
"x-api-key": instance.api_key,
|
||||
}
|
||||
|
||||
payload = {"is_signup_screen_visited": True}
|
||||
response = requests.patch(
|
||||
f"{license_engine_base_url}/api/instances/",
|
||||
headers=headers,
|
||||
data=json.dumps(payload),
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
# Python imports
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import User
|
||||
from plane.license.models import Instance, InstanceAdmin
|
||||
|
||||
|
||||
def instance_verification(instance):
|
||||
with open("package.json", "r") as file:
|
||||
# Load JSON content from the file
|
||||
data = json.load(file)
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
payload = {
|
||||
"instance_key": settings.INSTANCE_KEY,
|
||||
"version": data.get("version", 0.1),
|
||||
"machine_signature": os.environ.get("MACHINE_SIGNATURE", "machine-signature"),
|
||||
"user_count": User.objects.filter(is_bot=False).count(),
|
||||
}
|
||||
# Register the instance
|
||||
response = requests.post(
|
||||
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
|
||||
headers=headers,
|
||||
data=json.dumps(payload),
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
# check response status
|
||||
if response.status_code == 201:
|
||||
data = response.json()
|
||||
# Update instance
|
||||
instance.instance_id = data.get("id")
|
||||
instance.license_key = data.get("license_key")
|
||||
instance.api_key = data.get("api_key")
|
||||
instance.version = data.get("version")
|
||||
instance.user_count = data.get("user_count", 0)
|
||||
instance.is_verified = True
|
||||
instance.save()
|
||||
else:
|
||||
return
|
||||
|
||||
|
||||
def admin_verification(instance):
|
||||
# Save the user in control center
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"x-instance-id": instance.instance_id,
|
||||
"x-api-key": instance.api_key,
|
||||
}
|
||||
|
||||
# Get all the unverified instance admins
|
||||
instance_admins = InstanceAdmin.objects.filter(is_verified=False).select_related(
|
||||
"user"
|
||||
)
|
||||
updated_instance_admin = []
|
||||
|
||||
# Verify the instance admin
|
||||
for instance_admin in instance_admins:
|
||||
instance_admin.is_verified = True
|
||||
# Create the admin
|
||||
response = requests.post(
|
||||
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/register/",
|
||||
headers=headers,
|
||||
data=json.dumps(
|
||||
{
|
||||
"email": str(instance_admin.user.email),
|
||||
"signup_mode": "EMAIL",
|
||||
"is_admin": True,
|
||||
}
|
||||
),
|
||||
timeout=30,
|
||||
)
|
||||
updated_instance_admin.append(instance_admin)
|
||||
|
||||
# update all the instance admins
|
||||
InstanceAdmin.objects.bulk_update(
|
||||
updated_instance_admin, ["is_verified"], batch_size=10
|
||||
)
|
||||
return
|
||||
|
||||
def instance_user_count(instance):
|
||||
try:
|
||||
instance_users = User.objects.filter(is_bot=False).count()
|
||||
|
||||
# Update the count in the license engine
|
||||
payload = {
|
||||
"user_count": instance_users,
|
||||
}
|
||||
|
||||
# Save the user in control center
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"x-instance-id": instance.instance_id,
|
||||
"x-api-key": instance.api_key,
|
||||
}
|
||||
|
||||
# Update the license engine
|
||||
_ = requests.post(
|
||||
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
|
||||
headers=headers,
|
||||
data=json.dumps(payload),
|
||||
timeout=30,
|
||||
)
|
||||
return
|
||||
except requests.RequestException:
|
||||
return
|
||||
|
||||
|
||||
@shared_task
|
||||
def instance_verification_task():
|
||||
try:
|
||||
# Get the first instance
|
||||
instance = Instance.objects.first()
|
||||
|
||||
# Only register instance if it is not verified
|
||||
if not instance.is_verified:
|
||||
instance_verification(instance=instance)
|
||||
|
||||
# Admin verifications
|
||||
admin_verification(instance=instance)
|
||||
|
||||
# Update user count
|
||||
instance_user_count(instance=instance)
|
||||
|
||||
return
|
||||
except requests.RequestException:
|
||||
return
|
||||
@@ -21,91 +21,84 @@ class Command(BaseCommand):
|
||||
"key": "ENABLE_SIGNUP",
|
||||
"value": os.environ.get("ENABLE_SIGNUP", "1"),
|
||||
"category": "AUTHENTICATION",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "ENABLE_EMAIL_PASSWORD",
|
||||
"value": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"),
|
||||
"category": "AUTHENTICATION",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "ENABLE_MAGIC_LINK_LOGIN",
|
||||
"value": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "0"),
|
||||
"category": "AUTHENTICATION",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "GOOGLE_CLIENT_ID",
|
||||
"value": os.environ.get("GOOGLE_CLIENT_ID"),
|
||||
"category": "GOOGLE",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "GITHUB_CLIENT_ID",
|
||||
"value": os.environ.get("GITHUB_CLIENT_ID"),
|
||||
"category": "GITHUB",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "GITHUB_CLIENT_SECRET",
|
||||
"value": os.environ.get("GITHUB_CLIENT_SECRET"),
|
||||
"value": encrypt_data(os.environ.get("GITHUB_CLIENT_SECRET"))
|
||||
if os.environ.get("GITHUB_CLIENT_SECRET")
|
||||
else None,
|
||||
"category": "GITHUB",
|
||||
"is_encrypted": True,
|
||||
},
|
||||
{
|
||||
"key": "EMAIL_HOST",
|
||||
"value": os.environ.get("EMAIL_HOST", ""),
|
||||
"category": "SMTP",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "EMAIL_HOST_USER",
|
||||
"value": os.environ.get("EMAIL_HOST_USER", ""),
|
||||
"category": "SMTP",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "EMAIL_HOST_PASSWORD",
|
||||
"value": os.environ.get("EMAIL_HOST_PASSWORD", ""),
|
||||
"value": encrypt_data(os.environ.get("EMAIL_HOST_PASSWORD"))
|
||||
if os.environ.get("EMAIL_HOST_PASSWORD")
|
||||
else None,
|
||||
"category": "SMTP",
|
||||
"is_encrypted": True,
|
||||
},
|
||||
{
|
||||
"key": "EMAIL_PORT",
|
||||
"value": os.environ.get("EMAIL_PORT", "587"),
|
||||
"category": "SMTP",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "EMAIL_FROM",
|
||||
"value": os.environ.get("EMAIL_FROM", ""),
|
||||
"category": "SMTP",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "EMAIL_USE_TLS",
|
||||
"value": os.environ.get("EMAIL_USE_TLS", "1"),
|
||||
"category": "SMTP",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "OPENAI_API_KEY",
|
||||
"value": os.environ.get("OPENAI_API_KEY"),
|
||||
"value": encrypt_data(os.environ.get("OPENAI_API_KEY"))
|
||||
if os.environ.get("OPENAI_API_KEY")
|
||||
else None,
|
||||
"category": "OPENAI",
|
||||
"is_encrypted": True,
|
||||
},
|
||||
{
|
||||
"key": "GPT_ENGINE",
|
||||
"value": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"),
|
||||
"category": "SMTP",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "UNSPLASH_ACCESS_KEY",
|
||||
"value": os.environ.get("UNSPLASH_ACESS_KEY", ""),
|
||||
"value": encrypt_data(os.environ.get("UNSPLASH_ACESS_KEY", ""))
|
||||
if os.environ.get("UNSPLASH_ACESS_KEY")
|
||||
else None,
|
||||
"category": "UNSPLASH",
|
||||
"is_encrypted": True,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -114,12 +107,8 @@ class Command(BaseCommand):
|
||||
key=item.get("key")
|
||||
)
|
||||
if created:
|
||||
obj.value = item.get("value")
|
||||
obj.category = item.get("category")
|
||||
obj.is_encrypted = item.get("is_encrypted", False)
|
||||
if item.get("is_encrypted", False):
|
||||
obj.value = encrypt_data(item.get("value"))
|
||||
else:
|
||||
obj.value = item.get("value")
|
||||
obj.save()
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
# Python imports
|
||||
import json
|
||||
import os
|
||||
import requests
|
||||
import secrets
|
||||
|
||||
# Django imports
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
|
||||
# Module imports
|
||||
from plane.license.models import Instance
|
||||
@@ -31,57 +30,48 @@ class Command(BaseCommand):
|
||||
data = json.load(file)
|
||||
|
||||
machine_signature = options.get("machine_signature", False)
|
||||
instance_key = os.environ.get("INSTANCE_KEY", False)
|
||||
|
||||
# Raise an exception if the admin email is not provided
|
||||
if not instance_key:
|
||||
raise CommandError("INSTANCE_KEY is required")
|
||||
|
||||
if not machine_signature:
|
||||
raise CommandError("Machine signature is required")
|
||||
|
||||
# Check if machine is online
|
||||
license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL")
|
||||
|
||||
if not license_engine_base_url:
|
||||
raise CommandError("LICENSE_ENGINE_BASE_URL is required")
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
payload = {
|
||||
"instance_key": settings.INSTANCE_KEY,
|
||||
"instance_key": instance_key,
|
||||
"version": data.get("version", 0.1),
|
||||
"machine_signature": machine_signature,
|
||||
"user_count": User.objects.filter(is_bot=False).count(),
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
|
||||
headers=headers,
|
||||
data=json.dumps(payload),
|
||||
timeout=30
|
||||
)
|
||||
response = requests.post(
|
||||
f"{license_engine_base_url}/api/instances/",
|
||||
headers=headers,
|
||||
data=json.dumps(payload),
|
||||
)
|
||||
|
||||
if response.status_code == 201:
|
||||
data = response.json()
|
||||
# Create instance
|
||||
instance = Instance.objects.create(
|
||||
instance_name="Plane Free",
|
||||
instance_id=data.get("id"),
|
||||
license_key=data.get("license_key"),
|
||||
api_key=data.get("api_key"),
|
||||
version=data.get("version"),
|
||||
last_checked_at=timezone.now(),
|
||||
user_count=data.get("user_count", 0),
|
||||
is_verified=True,
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Instance successfully registered and verified"
|
||||
)
|
||||
)
|
||||
return
|
||||
except requests.RequestException as _e:
|
||||
if response.status_code == 201:
|
||||
data = response.json()
|
||||
# Create instance
|
||||
instance = Instance.objects.create(
|
||||
instance_name="Plane Free",
|
||||
instance_id=secrets.token_hex(12),
|
||||
license_key=None,
|
||||
api_key=secrets.token_hex(8),
|
||||
version=payload.get("version"),
|
||||
instance_id=data.get("id"),
|
||||
license_key=data.get("license_key"),
|
||||
api_key=data.get("api_key"),
|
||||
version=data.get("version"),
|
||||
last_checked_at=timezone.now(),
|
||||
user_count=payload.get("user_count", 0),
|
||||
user_count=data.get("user_count", 0),
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Instance successfully registered"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 4.2.7 on 2023-12-06 06:49
|
||||
# Generated by Django 4.2.7 on 2023-11-29 14:39
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
@@ -34,7 +34,6 @@ class Migration(migrations.Migration):
|
||||
('is_setup_done', models.BooleanField(default=False)),
|
||||
('is_signup_screen_visited', models.BooleanField(default=False)),
|
||||
('user_count', models.PositiveBigIntegerField(default=0)),
|
||||
('is_verified', models.BooleanField(default=False)),
|
||||
('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')),
|
||||
('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')),
|
||||
],
|
||||
@@ -54,7 +53,6 @@ class Migration(migrations.Migration):
|
||||
('key', models.CharField(max_length=100, unique=True)),
|
||||
('value', models.TextField(blank=True, default=None, null=True)),
|
||||
('category', models.TextField()),
|
||||
('is_encrypted', models.BooleanField(default=False)),
|
||||
('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')),
|
||||
('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')),
|
||||
],
|
||||
@@ -72,7 +70,6 @@ class Migration(migrations.Migration):
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('role', models.PositiveIntegerField(choices=[(20, 'Admin')], default=20)),
|
||||
('is_verified', models.BooleanField(default=False)),
|
||||
('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')),
|
||||
('instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='admins', to='license.instance')),
|
||||
('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')),
|
||||
|
||||
@@ -30,7 +30,6 @@ class Instance(BaseModel):
|
||||
is_signup_screen_visited = models.BooleanField(default=False)
|
||||
# users
|
||||
user_count = models.PositiveBigIntegerField(default=0)
|
||||
is_verified = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Instance"
|
||||
@@ -48,7 +47,6 @@ class InstanceAdmin(BaseModel):
|
||||
)
|
||||
instance = models.ForeignKey(Instance, on_delete=models.CASCADE, related_name="admins")
|
||||
role = models.PositiveIntegerField(choices=ROLE_CHOICES, default=20)
|
||||
is_verified = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["instance", "user"]
|
||||
@@ -63,7 +61,6 @@ class InstanceConfiguration(BaseModel):
|
||||
key = models.CharField(max_length=100, unique=True)
|
||||
value = models.TextField(null=True, blank=True, default=None)
|
||||
category = models.TextField()
|
||||
is_encrypted = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Instance Configuration"
|
||||
|
||||
@@ -4,7 +4,9 @@ from plane.license.api.views import (
|
||||
InstanceEndpoint,
|
||||
InstanceAdminEndpoint,
|
||||
InstanceConfigurationEndpoint,
|
||||
InstanceAdminSignInEndpoint,
|
||||
AdminMagicSignInGenerateEndpoint,
|
||||
AdminSetupMagicSignInEndpoint,
|
||||
AdminSetUserPasswordEndpoint,
|
||||
SignUpScreenVisitedEndpoint,
|
||||
)
|
||||
|
||||
@@ -30,9 +32,19 @@ urlpatterns = [
|
||||
name="instance-configuration",
|
||||
),
|
||||
path(
|
||||
"instances/admins/sign-in/",
|
||||
InstanceAdminSignInEndpoint.as_view(),
|
||||
name="instance-admin-sign-in",
|
||||
"instances/admins/magic-generate/",
|
||||
AdminMagicSignInGenerateEndpoint.as_view(),
|
||||
name="instance-admins",
|
||||
),
|
||||
path(
|
||||
"instances/admins/magic-sign-in/",
|
||||
AdminSetupMagicSignInEndpoint.as_view(),
|
||||
name="instance-admins",
|
||||
),
|
||||
path(
|
||||
"instances/admins/set-password/",
|
||||
AdminSetUserPasswordEndpoint.as_view(),
|
||||
name="instance-admins",
|
||||
),
|
||||
path(
|
||||
"instances/admins/sign-up-screen-visited/",
|
||||
|
||||
@@ -48,6 +48,7 @@ INSTALLED_APPS = [
|
||||
"rest_framework.authtoken",
|
||||
"rest_framework_simplejwt.token_blacklist",
|
||||
"corsheaders",
|
||||
"taggit",
|
||||
"django_celery_beat",
|
||||
"storages",
|
||||
]
|
||||
@@ -109,9 +110,7 @@ CSRF_COOKIE_SECURE = True
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
cors_origins_raw = os.environ.get("CORS_ALLOWED_ORIGINS", "")
|
||||
# filter out empty strings
|
||||
cors_allowed_origins = [
|
||||
origin.strip() for origin in cors_origins_raw.split(",") if origin.strip()
|
||||
]
|
||||
cors_allowed_origins = [origin.strip() for origin in cors_origins_raw.split(",") if origin.strip()]
|
||||
if cors_allowed_origins:
|
||||
CORS_ALLOWED_ORIGINS = cors_allowed_origins
|
||||
else:
|
||||
@@ -287,12 +286,11 @@ CELERY_IMPORTS = (
|
||||
"plane.bgtasks.issue_automation_task",
|
||||
"plane.bgtasks.exporter_expired_task",
|
||||
"plane.bgtasks.file_asset_task",
|
||||
"plane.license.bgtasks.instance_verification_task",
|
||||
)
|
||||
|
||||
# Sentry Settings
|
||||
# Enable Sentry Settings
|
||||
if bool(os.environ.get("SENTRY_DSN", False)) and os.environ.get("SENTRY_DSN").startswith("https://"):
|
||||
if bool(os.environ.get("SENTRY_DSN", False)):
|
||||
sentry_sdk.init(
|
||||
dsn=os.environ.get("SENTRY_DSN", ""),
|
||||
integrations=[
|
||||
@@ -326,14 +324,4 @@ USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1
|
||||
|
||||
# Posthog settings
|
||||
POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY", False)
|
||||
POSTHOG_HOST = os.environ.get("POSTHOG_HOST", False)
|
||||
|
||||
# License engine base url
|
||||
LICENSE_ENGINE_BASE_URL = os.environ.get(
|
||||
"LICENSE_ENGINE_BASE_URL", "https://control-center.plane.so"
|
||||
)
|
||||
|
||||
# instance key
|
||||
INSTANCE_KEY = os.environ.get(
|
||||
"INSTANCE_KEY", "ae6517d563dfc13d8270bd45cf17b08f70b37d989128a9dab46ff687603333c3"
|
||||
)
|
||||
POSTHOG_HOST = os.environ.get("POSTHOG_HOST", False)
|
||||
@@ -63,7 +63,7 @@ def date_filter(filter, date_term, queries):
|
||||
duration=int(digit),
|
||||
subsequent=date_query[1],
|
||||
term=term,
|
||||
date_filter=date_term,
|
||||
date_filter="created_at__date",
|
||||
offset=date_query[2],
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
# base requirements
|
||||
|
||||
Django==4.2.7
|
||||
django-braces==1.15.0
|
||||
django-taggit==4.0.0
|
||||
psycopg==3.1.12
|
||||
django-oauth-toolkit==2.3.0
|
||||
mistune==3.0.1
|
||||
djangorestframework==3.14.0
|
||||
redis==4.6.0
|
||||
django-nested-admin==4.0.2
|
||||
django-cors-headers==4.2.0
|
||||
whitenoise==6.5.0
|
||||
django-allauth==0.55.2
|
||||
@@ -14,6 +19,8 @@ djangorestframework-simplejwt==5.3.0
|
||||
sentry-sdk==1.30.0
|
||||
django-storages==1.14
|
||||
django-crum==0.7.9
|
||||
django-guardian==2.4.0
|
||||
dj_rest_auth==2.2.5
|
||||
google-auth==2.22.0
|
||||
google-api-python-client==2.97.0
|
||||
django-redis==5.3.0
|
||||
@@ -31,6 +38,3 @@ beautifulsoup4==4.12.2
|
||||
dj-database-url==2.1.0
|
||||
posthog==3.0.2
|
||||
cryptography==41.0.5
|
||||
lxml==4.9.3
|
||||
boto3==1.28.40
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
-r base.txt
|
||||
|
||||
gunicorn==21.2.0
|
||||
whitenoise==6.5.0
|
||||
boto3==1.28.40
|
||||
django-anymail==10.1
|
||||
django-debug-toolbar==4.1.0
|
||||
gevent==23.7.0
|
||||
psycogreen==1.0.2
|
||||
@@ -149,20 +149,20 @@
|
||||
padding-top: 10px !important;
|
||||
}
|
||||
.r13-o {
|
||||
border-bottom-color: #d9e4ff !important;
|
||||
border-bottom-color: #efefef !important;
|
||||
border-bottom-width: 1px !important;
|
||||
border-left-color: #d9e4ff !important;
|
||||
border-left-color: #efefef !important;
|
||||
border-left-width: 1px !important;
|
||||
border-right-color: #d9e4ff !important;
|
||||
border-right-color: #efefef !important;
|
||||
border-right-width: 1px !important;
|
||||
border-style: solid !important;
|
||||
border-top-color: #d9e4ff !important;
|
||||
border-top-color: #efefef !important;
|
||||
border-top-width: 1px !important;
|
||||
margin: 0 auto 0 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
.r14-i {
|
||||
background-color: #ecf1ff !important;
|
||||
background-color: #e3e6f1 !important;
|
||||
padding-bottom: 10px !important;
|
||||
padding-left: 10px !important;
|
||||
padding-right: 10px !important;
|
||||
@@ -225,11 +225,16 @@
|
||||
padding-top: 5px !important;
|
||||
}
|
||||
.r24-o {
|
||||
border-style: solid !important;
|
||||
margin-right: 8px !important;
|
||||
width: 32px !important;
|
||||
}
|
||||
.r25-o {
|
||||
border-style: solid !important;
|
||||
margin-right: 0px !important;
|
||||
width: 32px !important;
|
||||
}
|
||||
.r25-i {
|
||||
.r26-i {
|
||||
padding-bottom: 0px !important;
|
||||
padding-top: 5px !important;
|
||||
text-align: center !important;
|
||||
@@ -659,17 +664,17 @@
|
||||
width="100%"
|
||||
class="r13-o"
|
||||
style="
|
||||
background-color: #ecf1ff;
|
||||
border-bottom-color: #d9e4ff;
|
||||
background-color: #e3e6f1;
|
||||
border-bottom-color: #efefef;
|
||||
border-bottom-width: 1px;
|
||||
border-collapse: separate;
|
||||
border-left-color: #d9e4ff;
|
||||
border-left-color: #efefef;
|
||||
border-left-width: 1px;
|
||||
border-radius: 5px;
|
||||
border-right-color: #d9e4ff;
|
||||
border-right-color: #efefef;
|
||||
border-right-width: 1px;
|
||||
border-style: solid;
|
||||
border-top-color: #d9e4ff;
|
||||
border-top-color: #efefef;
|
||||
border-top-width: 1px;
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
@@ -685,7 +690,7 @@
|
||||
font-family: georgia, serif;
|
||||
font-size: 16px;
|
||||
word-break: break-word;
|
||||
background-color: #ecf1ff;
|
||||
background-color: #e3e6f1;
|
||||
border-radius: 4px;
|
||||
line-height: 3;
|
||||
padding-bottom: 10px;
|
||||
@@ -709,10 +714,10 @@
|
||||
<p style="margin: 0">
|
||||
<span
|
||||
style="
|
||||
color: #5f5e5e;
|
||||
color: #716c6c;
|
||||
font-family: Arial,
|
||||
helvetica, sans-serif;
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
"
|
||||
>Please copy and paste this
|
||||
on the screen where you
|
||||
@@ -1246,14 +1251,13 @@
|
||||
role="presentation"
|
||||
width="100%"
|
||||
class="r22-o"
|
||||
class="r24-o"
|
||||
style="
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
"
|
||||
>
|
||||
<tr>
|
||||
<td
|
||||
class="r23-i"
|
||||
class="r17-i"
|
||||
style="
|
||||
font-size: 0px;
|
||||
line-height: 0px;
|
||||
@@ -1305,7 +1309,7 @@
|
||||
border="0"
|
||||
role="presentation"
|
||||
width="100%"
|
||||
class="r24-o"
|
||||
class="r25-o"
|
||||
style="
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
@@ -1437,7 +1441,7 @@
|
||||
<td
|
||||
align="center"
|
||||
valign="top"
|
||||
class="r25-i nl2go-default-textstyle"
|
||||
class="r26-i nl2go-default-textstyle"
|
||||
style="
|
||||
color: #3b3f44;
|
||||
font-family: georgia, serif;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
|
||||
<html>
|
||||
Dear {{username}},<br/>
|
||||
Your requested Issue's data has been successfully exported from Plane. The export includes all relevant information about issues you requested from your selected projects.</br>
|
||||
Please find the attachment and download the CSV file. If you have any questions or need further assistance, please don't hesitate to contact our support team at <a href = "mailto: engineering@plane.com">engineering@plane.so</a>. We're here to help!</br>
|
||||
Thank you for using Plane. We hope this export will aid you in effectively managing your projects.</br>
|
||||
Regards,
|
||||
Team Plane
|
||||
</html>
|
||||
@@ -71,6 +71,7 @@ services:
|
||||
- ENABLE_MAGIC_LINK_LOGIN=${ENABLE_MAGIC_LINK_LOGIN:-0}
|
||||
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
|
||||
- WEB_URL=$SERVICE_FQDN_PLANE_8082
|
||||
- LICENSE_ENGINE_BASE_URL=${LICENSE_ENGINE_BASE_URL:-"https://control-center.plane.so"}
|
||||
depends_on:
|
||||
- plane-db
|
||||
- plane-redis
|
||||
@@ -116,6 +117,7 @@ services:
|
||||
- DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123}
|
||||
- ENABLE_SIGNUP=${ENABLE_SIGNUP:-1}
|
||||
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
|
||||
- LICENSE_ENGINE_BASE_URL=${LICENSE_ENGINE_BASE_URL:-"https://control-center.plane.so"}
|
||||
depends_on:
|
||||
- api
|
||||
- plane-db
|
||||
@@ -162,6 +164,7 @@ services:
|
||||
- DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123}
|
||||
- ENABLE_SIGNUP=${ENABLE_SIGNUP:-1}
|
||||
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
|
||||
- LICENSE_ENGINE_BASE_URL=${LICENSE_ENGINE_BASE_URL:-"https://control-center.plane.so"}
|
||||
depends_on:
|
||||
- api
|
||||
- plane-db
|
||||
|
||||
@@ -5,16 +5,15 @@ x-app-env : &app-env
|
||||
- NGINX_PORT=${NGINX_PORT:-80}
|
||||
- WEB_URL=${WEB_URL:-http://localhost}
|
||||
- DEBUG=${DEBUG:-0}
|
||||
- DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-plane.settings.production} # deprecated
|
||||
- NEXT_PUBLIC_ENABLE_OAUTH=${NEXT_PUBLIC_ENABLE_OAUTH:-0} # deprecated
|
||||
- NEXT_PUBLIC_DEPLOY_URL=${NEXT_PUBLIC_DEPLOY_URL:-http://localhost/spaces} # deprecated
|
||||
- NEXT_PUBLIC_ENABLE_OAUTH=${NEXT_PUBLIC_ENABLE_OAUTH:-0}
|
||||
- NEXT_PUBLIC_DEPLOY_URL=${NEXT_PUBLIC_DEPLOY_URL:-http://localhost/spaces}
|
||||
- SENTRY_DSN=${SENTRY_DSN:-""}
|
||||
- SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-"production"}
|
||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-""}
|
||||
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-""}
|
||||
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""}
|
||||
- DOCKERIZED=${DOCKERIZED:-1} # deprecated
|
||||
- CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-""}
|
||||
- SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-"production"}
|
||||
- ADMIN_EMAIL=${ADMIN_EMAIL:-""}
|
||||
- LICENSE_ENGINE_BASE_URL=${LICENSE_ENGINE_BASE_URL:-""}
|
||||
# Gunicorn Workers
|
||||
- GUNICORN_WORKERS=${GUNICORN_WORKERS:-2}
|
||||
#DB SETTINGS
|
||||
@@ -29,12 +28,12 @@ x-app-env : &app-env
|
||||
- REDIS_HOST=${REDIS_HOST:-plane-redis}
|
||||
- REDIS_PORT=${REDIS_PORT:-6379}
|
||||
- REDIS_URL=${REDIS_URL:-redis://${REDIS_HOST}:6379/}
|
||||
# EMAIL SETTINGS - Deprecated can be configured through admin panel
|
||||
# EMAIL SETTINGS
|
||||
- EMAIL_HOST=${EMAIL_HOST:-""}
|
||||
- EMAIL_HOST_USER=${EMAIL_HOST_USER:-""}
|
||||
- EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-""}
|
||||
- EMAIL_PORT=${EMAIL_PORT:-587}
|
||||
- EMAIL_FROM=${EMAIL_FROM:-"Team Plane <team@mailer.plane.so>"}
|
||||
- EMAIL_FROM=${EMAIL_FROM:-"Team Plane <team@mailer.plane.so>"}
|
||||
- EMAIL_USE_TLS=${EMAIL_USE_TLS:-1}
|
||||
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-0}
|
||||
- DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so}
|
||||
@@ -43,11 +42,10 @@ x-app-env : &app-env
|
||||
- OPENAI_API_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-"sk-"}
|
||||
- GPT_ENGINE=${GPT_ENGINE:-"gpt-3.5-turbo"}
|
||||
# LOGIN/SIGNUP SETTINGS - Deprecated can be configured through admin panel
|
||||
# LOGIN/SIGNUP SETTINGS
|
||||
- ENABLE_SIGNUP=${ENABLE_SIGNUP:-1}
|
||||
- ENABLE_EMAIL_PASSWORD=${ENABLE_EMAIL_PASSWORD:-1}
|
||||
- ENABLE_MAGIC_LINK_LOGIN=${ENABLE_MAGIC_LINK_LOGIN:-0}
|
||||
# Application secret
|
||||
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
|
||||
# DATA STORE SETTINGS
|
||||
- USE_MINIO=${USE_MINIO:-1}
|
||||
@@ -67,7 +65,7 @@ services:
|
||||
web:
|
||||
<<: *app-env
|
||||
platform: linux/amd64
|
||||
image: makeplane/plane-frontend-ce:${APP_RELEASE:-latest}
|
||||
image: makeplane/plane-frontend:${APP_RELEASE:-latest}
|
||||
restart: unless-stopped
|
||||
command: /usr/local/bin/start.sh web/server.js web
|
||||
deploy:
|
||||
@@ -79,7 +77,7 @@ services:
|
||||
space:
|
||||
<<: *app-env
|
||||
platform: linux/amd64
|
||||
image: makeplane/plane-space-ce:${APP_RELEASE:-latest}
|
||||
image: makeplane/plane-space:${APP_RELEASE:-latest}
|
||||
restart: unless-stopped
|
||||
command: /usr/local/bin/start.sh space/server.js space
|
||||
deploy:
|
||||
@@ -92,7 +90,7 @@ services:
|
||||
api:
|
||||
<<: *app-env
|
||||
platform: linux/amd64
|
||||
image: makeplane/plane-backend-ce:${APP_RELEASE:-latest}
|
||||
image: makeplane/plane-backend:${APP_RELEASE:-latest}
|
||||
restart: unless-stopped
|
||||
command: ./bin/takeoff
|
||||
deploy:
|
||||
@@ -104,7 +102,7 @@ services:
|
||||
worker:
|
||||
<<: *app-env
|
||||
platform: linux/amd64
|
||||
image: makeplane/plane-backend-ce:${APP_RELEASE:-latest}
|
||||
image: makeplane/plane-backend:${APP_RELEASE:-latest}
|
||||
restart: unless-stopped
|
||||
command: ./bin/worker
|
||||
depends_on:
|
||||
@@ -115,7 +113,7 @@ services:
|
||||
beat-worker:
|
||||
<<: *app-env
|
||||
platform: linux/amd64
|
||||
image: makeplane/plane-backend-ce:${APP_RELEASE:-latest}
|
||||
image: makeplane/plane-backend:${APP_RELEASE:-latest}
|
||||
restart: unless-stopped
|
||||
command: ./bin/beat
|
||||
depends_on:
|
||||
@@ -150,7 +148,7 @@ services:
|
||||
proxy:
|
||||
<<: *app-env
|
||||
platform: linux/amd64
|
||||
image: makeplane/plane-proxy-ce:${APP_RELEASE:-latest}
|
||||
image: makeplane/plane-proxy:${APP_RELEASE:-latest}
|
||||
ports:
|
||||
- ${NGINX_PORT}:80
|
||||
depends_on:
|
||||
|
||||
Executable
+125
@@ -0,0 +1,125 @@
|
||||
#!/bin/bash
|
||||
|
||||
BRANCH=develop
|
||||
SCRIPT_DIR=$PWD
|
||||
PLANE_INSTALL_DIR=$PWD/plane-app-private
|
||||
|
||||
function install(){
|
||||
echo
|
||||
echo "Installing on $PLANE_INSTALL_DIR"
|
||||
download
|
||||
}
|
||||
function download(){
|
||||
cd $SCRIPT_DIR
|
||||
TS=$(date +%s)
|
||||
if [ -f "$PLANE_INSTALL_DIR/docker-compose.yaml" ]
|
||||
then
|
||||
mv $PLANE_INSTALL_DIR/docker-compose.yaml $PLANE_INSTALL_DIR/archive/$TS.docker-compose.yaml
|
||||
fi
|
||||
|
||||
curl -H 'Cache-Control: no-cache, no-store' -s -o $PLANE_INSTALL_DIR/docker-compose.yaml https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/docker-compose.yml?$(date +%s)
|
||||
curl -H 'Cache-Control: no-cache, no-store' -s -o $PLANE_INSTALL_DIR/variables-upgrade.env https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/variables.env?$(date +%s)
|
||||
|
||||
if [ -f "$PLANE_INSTALL_DIR/.env" ];
|
||||
then
|
||||
cp $PLANE_INSTALL_DIR/.env $PLANE_INSTALL_DIR/archive/$TS.env
|
||||
else
|
||||
mv $PLANE_INSTALL_DIR/variables-upgrade.env $PLANE_INSTALL_DIR/.env
|
||||
fi
|
||||
|
||||
cp $PLANE_INSTALL_DIR/docker-compose.yaml $PLANE_INSTALL_DIR/temp.yaml
|
||||
sed -e 's@plane-frontend:@plane-frontend-private:@g' \
|
||||
-e 's@plane-space:@plane-space-private:@g' \
|
||||
-e 's@plane-backend:@plane-backend-private:@g' \
|
||||
-e 's@plane-proxy:@plane-proxy-private:@g' \
|
||||
-e 's@${APP_RELEASE:-latest}@'"$BRANCH"'@g' \
|
||||
$PLANE_INSTALL_DIR/temp.yaml > $PLANE_INSTALL_DIR/docker-compose.yaml
|
||||
|
||||
rm $PLANE_INSTALL_DIR/temp.yaml
|
||||
|
||||
echo ""
|
||||
echo "Latest version is now available for you to use"
|
||||
echo ""
|
||||
echo "In case of Upgrade, your new setting file is availabe as 'variables-upgrade.env'. Please compare and set the required values in '.env 'file."
|
||||
echo ""
|
||||
|
||||
}
|
||||
function startServices(){
|
||||
cd $PLANE_INSTALL_DIR
|
||||
docker compose up -d
|
||||
cd $SCRIPT_DIR
|
||||
}
|
||||
function stopServices(){
|
||||
cd $PLANE_INSTALL_DIR
|
||||
docker compose down
|
||||
cd $SCRIPT_DIR
|
||||
}
|
||||
function restartServices(){
|
||||
cd $PLANE_INSTALL_DIR
|
||||
docker compose restart
|
||||
cd $SCRIPT_DIR
|
||||
}
|
||||
function upgrade(){
|
||||
echo "***** STOPPING SERVICES ****"
|
||||
stopServices
|
||||
|
||||
echo
|
||||
echo "***** DOWNLOADING LATEST VERSION ****"
|
||||
download
|
||||
|
||||
echo "***** PLEASE VALIDATE AND START SERVICES ****"
|
||||
|
||||
}
|
||||
function askForAction(){
|
||||
echo
|
||||
echo "Select a Action you want to perform:"
|
||||
echo " 1) Install"
|
||||
echo " 2) Start"
|
||||
echo " 3) Stop"
|
||||
echo " 4) Restart"
|
||||
echo " 5) Upgrade"
|
||||
echo " 6) Exit"
|
||||
echo
|
||||
read -p "Action [2]: " ACTION
|
||||
until [[ -z "$ACTION" || "$ACTION" =~ ^[1-6]$ ]]; do
|
||||
echo "$ACTION: invalid selection."
|
||||
read -p "Action [2]: " ACTION
|
||||
done
|
||||
echo
|
||||
|
||||
|
||||
if [ "$ACTION" == "1" ]
|
||||
then
|
||||
install
|
||||
askForAction
|
||||
elif [ "$ACTION" == "2" ] || [ "$ACTION" == "" ]
|
||||
then
|
||||
startServices
|
||||
askForAction
|
||||
elif [ "$ACTION" == "3" ]
|
||||
then
|
||||
stopServices
|
||||
askForAction
|
||||
elif [ "$ACTION" == "4" ]
|
||||
then
|
||||
restartServices
|
||||
askForAction
|
||||
elif [ "$ACTION" == "5" ]
|
||||
then
|
||||
upgrade
|
||||
askForAction
|
||||
elif [ "$ACTION" == "6" ]
|
||||
then
|
||||
exit 0
|
||||
else
|
||||
echo "INVALID ACTION SUPPLIED"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$BRANCH" != "master" ];
|
||||
then
|
||||
PLANE_INSTALL_DIR=$PWD/plane-app-private-$(echo $BRANCH | sed -r 's@(\/|" "|\.)@-@g')
|
||||
fi
|
||||
mkdir -p $PLANE_INSTALL_DIR/archive
|
||||
|
||||
askForAction
|
||||
@@ -10,12 +10,10 @@ DEBUG=0
|
||||
NEXT_PUBLIC_ENABLE_OAUTH=0
|
||||
NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces
|
||||
SENTRY_DSN=""
|
||||
SENTRY_ENVIRONMENT="production"
|
||||
GOOGLE_CLIENT_ID=""
|
||||
GITHUB_CLIENT_ID=""
|
||||
GITHUB_CLIENT_SECRET=""
|
||||
DOCKERIZED=1 # deprecated
|
||||
CORS_ALLOWED_ORIGINS="http://localhost"
|
||||
SENTRY_ENVIRONMENT="production"
|
||||
|
||||
#DB SETTINGS
|
||||
PGHOST=plane-db
|
||||
@@ -36,7 +34,7 @@ EMAIL_HOST=""
|
||||
EMAIL_HOST_USER=""
|
||||
EMAIL_HOST_PASSWORD=""
|
||||
EMAIL_PORT=587
|
||||
EMAIL_FROM="Team Plane <team@mailer.plane.so>"
|
||||
EMAIL_FROM="Team Plane <team@mailer.plane.so>"
|
||||
EMAIL_USE_TLS=1
|
||||
EMAIL_USE_SSL=0
|
||||
|
||||
@@ -65,3 +63,9 @@ FILE_SIZE_LIMIT=5242880
|
||||
|
||||
# Gunicorn Workers
|
||||
GUNICORN_WORKERS=2
|
||||
|
||||
# Admin Email
|
||||
ADMIN_EMAIL=""
|
||||
|
||||
# License Engine url
|
||||
LICENSE_ENGINE_BASE_URL=""
|
||||
+1
-1
@@ -30,7 +30,7 @@
|
||||
"turbo": "^1.10.16"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "18.2.42"
|
||||
"@types/react": "18.2.39"
|
||||
},
|
||||
"packageManager": "yarn@1.22.19"
|
||||
}
|
||||
|
||||
@@ -28,9 +28,8 @@
|
||||
"react-dom": "18.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@plane/editor-types": "*",
|
||||
"@tiptap/core": "^2.1.7",
|
||||
"@tiptap/extension-blockquote": "^2.1.13",
|
||||
"@plane/editor-types": "*",
|
||||
"@tiptap/extension-code-block-lowlight": "^2.1.12",
|
||||
"@tiptap/extension-color": "^2.1.11",
|
||||
"@tiptap/extension-image": "^2.1.7",
|
||||
@@ -62,12 +61,12 @@
|
||||
"tiptap-markdown": "^0.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "18.15.3",
|
||||
"@types/react": "^18.2.42",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-config-next": "13.2.4",
|
||||
"postcss": "^8.4.29",
|
||||
"eslint-config-next": "13.2.4",
|
||||
"@types/node": "18.15.3",
|
||||
"@types/react": "^18.2.39",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"tailwind-config-custom": "*",
|
||||
"tsconfig": "*",
|
||||
"tsup": "^7.2.0",
|
||||
|
||||
@@ -15,8 +15,8 @@ export { EditorContainer } from "./ui/components/editor-container";
|
||||
export { EditorContentWrapper } from "./ui/components/editor-content";
|
||||
|
||||
// hooks
|
||||
export { useEditor } from "./ui/hooks/use-editor";
|
||||
export { useReadOnlyEditor } from "./ui/hooks/use-read-only-editor";
|
||||
export { useEditor } from "./ui/hooks/useEditor";
|
||||
export { useReadOnlyEditor } from "./ui/hooks/useReadOnlyEditor";
|
||||
|
||||
// helper items
|
||||
export * from "./ui/menus/menu-items";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { useState } from "react";
|
||||
import Moveable from "react-moveable";
|
||||
|
||||
export const ImageResizer = ({ editor }: { editor: Editor }) => {
|
||||
@@ -18,8 +17,6 @@ export const ImageResizer = ({ editor }: { editor: Editor }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const [aspectRatio, setAspectRatio] = useState(1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Moveable
|
||||
@@ -31,29 +28,9 @@ export const ImageResizer = ({ editor }: { editor: Editor }) => {
|
||||
keepRatio
|
||||
resizable
|
||||
throttleResize={0}
|
||||
onResizeStart={() => {
|
||||
const imageInfo = document.querySelector(
|
||||
".ProseMirror-selectednode",
|
||||
) as HTMLImageElement;
|
||||
if (imageInfo) {
|
||||
const originalWidth = Number(imageInfo.width);
|
||||
const originalHeight = Number(imageInfo.height);
|
||||
setAspectRatio(originalWidth / originalHeight);
|
||||
}
|
||||
}}
|
||||
onResize={({ target, width, height, delta }: any) => {
|
||||
if (delta[0]) {
|
||||
const newWidth = Math.max(width, 100);
|
||||
const newHeight = newWidth / aspectRatio;
|
||||
target!.style.width = `${newWidth}px`;
|
||||
target!.style.height = `${newHeight}px`;
|
||||
}
|
||||
if (delta[1]) {
|
||||
const newHeight = Math.max(height, 100);
|
||||
const newWidth = newHeight * aspectRatio;
|
||||
target!.style.height = `${newHeight}px`;
|
||||
target!.style.width = `${newWidth}px`;
|
||||
}
|
||||
delta[0] && (target!.style.width = `${width}px`);
|
||||
delta[1] && (target!.style.height = `${height}px`);
|
||||
}}
|
||||
onResizeEnd={() => {
|
||||
updateMediaSize();
|
||||
|
||||
@@ -20,7 +20,6 @@ import { Mentions } from "../mentions";
|
||||
|
||||
import { CustomKeymap } from "./keymap";
|
||||
import { CustomCodeBlock } from "./code";
|
||||
import { CustomQuoteExtension } from "./quote";
|
||||
import { ListKeymap } from "./custom-list-keymap";
|
||||
import {
|
||||
IMentionSuggestion,
|
||||
@@ -35,7 +34,7 @@ export const CoreEditorExtensions = (
|
||||
},
|
||||
deleteFile: DeleteImage,
|
||||
restoreFile: RestoreImage,
|
||||
cancelUploadImage?: () => any
|
||||
cancelUploadImage?: () => any,
|
||||
) => [
|
||||
StarterKit.configure({
|
||||
bulletList: {
|
||||
@@ -53,11 +52,11 @@ export const CoreEditorExtensions = (
|
||||
class: "leading-normal -mb-2",
|
||||
},
|
||||
},
|
||||
// blockquote: {
|
||||
// HTMLAttributes: {
|
||||
// class: "border-l-4 border-custom-border-300",
|
||||
// },
|
||||
// },
|
||||
blockquote: {
|
||||
HTMLAttributes: {
|
||||
class: "border-l-4 border-custom-border-300",
|
||||
},
|
||||
},
|
||||
code: false,
|
||||
codeBlock: false,
|
||||
horizontalRule: false,
|
||||
@@ -66,9 +65,6 @@ export const CoreEditorExtensions = (
|
||||
width: 2,
|
||||
},
|
||||
}),
|
||||
CustomQuoteExtension.configure({
|
||||
HTMLAttributes: { className: "border-l-4 border-custom-border-300" },
|
||||
}),
|
||||
CustomKeymap,
|
||||
ListKeymap,
|
||||
TiptapLink.configure({
|
||||
@@ -112,6 +108,6 @@ export const CoreEditorExtensions = (
|
||||
Mentions(
|
||||
mentionConfig.mentionSuggestions,
|
||||
mentionConfig.mentionHighlights,
|
||||
false
|
||||
false,
|
||||
),
|
||||
];
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { isAtStartOfNode } from "@tiptap/core";
|
||||
import Blockquote from "@tiptap/extension-blockquote";
|
||||
|
||||
export const CustomQuoteExtension = Blockquote.extend({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Enter: ({ editor }) => {
|
||||
const { $from, $to, $head } = this.editor.state.selection;
|
||||
const parent = $head.node(-1);
|
||||
|
||||
if (!parent) return false;
|
||||
|
||||
if (parent.type.name !== "blockquote") {
|
||||
return false;
|
||||
}
|
||||
if ($from.pos !== $to.pos) return false;
|
||||
// if ($head.parentOffset < $head.parent.content.size) return false;
|
||||
|
||||
// this.editor.commands.insertContentAt(parent.ne);
|
||||
this.editor.chain().splitBlock().lift(this.name).run();
|
||||
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
+4
-6
@@ -4,6 +4,7 @@ import { CoreEditorProps } from "../props";
|
||||
import { CoreEditorExtensions } from "../extensions";
|
||||
import { EditorProps } from "@tiptap/pm/view";
|
||||
import { getTrimmedHTML } from "../../lib/utils";
|
||||
import { useInitializedContent } from "./useInitializedContent";
|
||||
import {
|
||||
DeleteImage,
|
||||
IMentionSuggestion,
|
||||
@@ -14,10 +15,6 @@ import {
|
||||
interface CustomEditorProps {
|
||||
uploadFile: UploadImage;
|
||||
restoreFile: RestoreImage;
|
||||
rerenderOnPropsChange?: {
|
||||
id: string;
|
||||
description_html: string;
|
||||
};
|
||||
deleteFile: DeleteImage;
|
||||
cancelUploadImage?: () => any;
|
||||
setIsSubmitting?: (
|
||||
@@ -41,7 +38,6 @@ export const useEditor = ({
|
||||
cancelUploadImage,
|
||||
editorProps = {},
|
||||
value,
|
||||
rerenderOnPropsChange,
|
||||
extensions = [],
|
||||
onStart,
|
||||
onChange,
|
||||
@@ -82,9 +78,11 @@ export const useEditor = ({
|
||||
onChange?.(editor.getJSON(), getTrimmedHTML(editor.getHTML()));
|
||||
},
|
||||
},
|
||||
[rerenderOnPropsChange],
|
||||
[],
|
||||
);
|
||||
|
||||
useInitializedContent(editor, value);
|
||||
|
||||
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
||||
editorRef.current = editor;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export const useInitializedContent = (editor: Editor | null, value: string) => {
|
||||
const hasInitializedContent = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
const cleanedValue =
|
||||
typeof value === "string" && value.trim() !== "" ? value : "<p></p>";
|
||||
if (cleanedValue !== "<p></p>" && !hasInitializedContent.current) {
|
||||
editor.commands.setContent(cleanedValue);
|
||||
hasInitializedContent.current = true;
|
||||
} else if (cleanedValue === "<p></p>" && hasInitializedContent.current) {
|
||||
hasInitializedContent.current = false;
|
||||
}
|
||||
}
|
||||
}, [value, editor]);
|
||||
};
|
||||
+31
-26
@@ -1,7 +1,12 @@
|
||||
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
|
||||
import { useImperativeHandle, useRef, MutableRefObject } from "react";
|
||||
import { CoreReadOnlyEditorExtensions } from "../read-only/extensions";
|
||||
import { CoreReadOnlyEditorProps } from "../read-only/props";
|
||||
import {
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
MutableRefObject,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { CoreReadOnlyEditorExtensions } from "../../ui/read-only/extensions";
|
||||
import { CoreReadOnlyEditorProps } from "../../ui/read-only/props";
|
||||
import { EditorProps } from "@tiptap/pm/view";
|
||||
import { IMentionSuggestion } from "@plane/editor-types";
|
||||
|
||||
@@ -10,10 +15,6 @@ interface CustomReadOnlyEditorProps {
|
||||
forwardedRef?: any;
|
||||
extensions?: any;
|
||||
editorProps?: EditorProps;
|
||||
rerenderOnPropsChange?: {
|
||||
id: string;
|
||||
description_html: string;
|
||||
};
|
||||
mentionHighlights?: string[];
|
||||
mentionSuggestions?: IMentionSuggestion[];
|
||||
}
|
||||
@@ -23,29 +24,33 @@ export const useReadOnlyEditor = ({
|
||||
forwardedRef,
|
||||
extensions = [],
|
||||
editorProps = {},
|
||||
rerenderOnPropsChange,
|
||||
mentionHighlights,
|
||||
mentionSuggestions,
|
||||
}: CustomReadOnlyEditorProps) => {
|
||||
const editor = useCustomEditor(
|
||||
{
|
||||
editable: false,
|
||||
content:
|
||||
typeof value === "string" && value.trim() !== "" ? value : "<p></p>",
|
||||
editorProps: {
|
||||
...CoreReadOnlyEditorProps,
|
||||
...editorProps,
|
||||
},
|
||||
extensions: [
|
||||
...CoreReadOnlyEditorExtensions({
|
||||
mentionSuggestions: mentionSuggestions ?? [],
|
||||
mentionHighlights: mentionHighlights ?? [],
|
||||
}),
|
||||
...extensions,
|
||||
],
|
||||
const editor = useCustomEditor({
|
||||
editable: false,
|
||||
content:
|
||||
typeof value === "string" && value.trim() !== "" ? value : "<p></p>",
|
||||
editorProps: {
|
||||
...CoreReadOnlyEditorProps,
|
||||
...editorProps,
|
||||
},
|
||||
[rerenderOnPropsChange],
|
||||
);
|
||||
extensions: [
|
||||
...CoreReadOnlyEditorExtensions({
|
||||
mentionSuggestions: mentionSuggestions ?? [],
|
||||
mentionHighlights: mentionHighlights ?? [],
|
||||
}),
|
||||
...extensions,
|
||||
],
|
||||
});
|
||||
|
||||
const hasIntiliazedContent = useRef(false);
|
||||
useEffect(() => {
|
||||
if (editor && !value && !hasIntiliazedContent.current) {
|
||||
editor.commands.setContent(value);
|
||||
hasIntiliazedContent.current = true;
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
||||
editorRef.current = editor;
|
||||
@@ -3,7 +3,7 @@ import * as React from "react";
|
||||
import { Extension } from "@tiptap/react";
|
||||
import { getEditorClassNames } from "../lib/utils";
|
||||
import { EditorProps } from "@tiptap/pm/view";
|
||||
import { useEditor } from "./hooks/use-editor";
|
||||
import { useEditor } from "./hooks/useEditor";
|
||||
import { EditorContainer } from "../ui/components/editor-container";
|
||||
import { EditorContentWrapper } from "../ui/components/editor-content";
|
||||
import {
|
||||
|
||||
@@ -28,27 +28,20 @@
|
||||
"react-dom": "18.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@plane/ui": "*",
|
||||
"@plane/editor-core": "*",
|
||||
"@plane/editor-extensions": "*",
|
||||
"@plane/editor-types": "*",
|
||||
"@plane/ui": "*",
|
||||
"@tiptap/core": "^2.1.7",
|
||||
"@tiptap/extension-placeholder": "^2.1.11",
|
||||
"@tiptap/pm": "^2.1.12",
|
||||
"@tiptap/suggestion": "^2.1.12",
|
||||
"@types/node": "18.15.3",
|
||||
"@types/react": "^18.2.39",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"eslint": "8.36.0",
|
||||
"eslint-config-next": "13.2.4",
|
||||
"react-popper": "^2.3.0",
|
||||
"tippy.js": "^6.3.7",
|
||||
"uuid": "^9.0.1"
|
||||
"react-popper": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "18.15.3",
|
||||
"@types/react": "^18.2.42",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"eslint": "^7.32.0",
|
||||
"postcss": "^8.4.29",
|
||||
"tailwind-config-custom": "*",
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
HeadingComp,
|
||||
HeadingThreeComp,
|
||||
SubheadingComp,
|
||||
} from "./heading-component";
|
||||
import { HeadingComp, SubheadingComp } from "./heading-component";
|
||||
import { IMarking } from "..";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { scrollSummary } from "../utils/editor-summary-utils";
|
||||
@@ -26,16 +22,11 @@ export const ContentBrowser = (props: ContentBrowserProps) => {
|
||||
onClick={() => scrollSummary(editor, marking)}
|
||||
heading={marking.text}
|
||||
/>
|
||||
) : marking.level === 2 ? (
|
||||
) : (
|
||||
<SubheadingComp
|
||||
onClick={() => scrollSummary(editor, marking)}
|
||||
subHeading={marking.text}
|
||||
/>
|
||||
) : (
|
||||
<HeadingThreeComp
|
||||
heading={marking.text}
|
||||
onClick={() => scrollSummary(editor, marking)}
|
||||
/>
|
||||
),
|
||||
)
|
||||
) : (
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { Archive, RefreshCw, Lock } from "lucide-react";
|
||||
import { IMarking } from "..";
|
||||
import { Archive, Info, Lock } from "lucide-react";
|
||||
import { IMarking, UploadImage } from "..";
|
||||
import { FixedMenu } from "../menu";
|
||||
import { UploadImage } from "@plane/editor-types";
|
||||
import { DocumentDetails } from "../types/editor-types";
|
||||
import { AlertLabel } from "./alert-label";
|
||||
import {
|
||||
@@ -27,7 +26,6 @@ interface IEditorHeader {
|
||||
isSubmitting: "submitting" | "submitted" | "saved",
|
||||
) => void;
|
||||
documentDetails: DocumentDetails;
|
||||
isSubmitting?: "submitting" | "submitted" | "saved";
|
||||
}
|
||||
|
||||
export const EditorHeader = (props: IEditorHeader) => {
|
||||
@@ -44,7 +42,6 @@ export const EditorHeader = (props: IEditorHeader) => {
|
||||
KanbanMenuOptions,
|
||||
isArchived,
|
||||
isLocked,
|
||||
isSubmitting,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
@@ -85,21 +82,6 @@ export const EditorHeader = (props: IEditorHeader) => {
|
||||
label={`Archived at ${new Date(archivedAt).toLocaleString()}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLocked && !isArchived ? (
|
||||
<div
|
||||
className={`flex absolute right-[120px] transition-all duration-300 items-center gap-x-2 ${
|
||||
isSubmitting === "saved" ? "fadeOut" : "fadeIn"
|
||||
}`}
|
||||
>
|
||||
{isSubmitting !== "submitted" && isSubmitting !== "saved" && (
|
||||
<RefreshCw className="h-4 w-4 stroke-custom-text-300" />
|
||||
)}
|
||||
<span className="text-sm text-custom-text-300">
|
||||
{isSubmitting === "submitting" ? "Saving..." : "Saved"}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{!isArchived && <InfoPopover documentDetails={documentDetails} />}
|
||||
<VerticalDropdownMenu items={KanbanMenuOptions} />
|
||||
</div>
|
||||
|
||||
@@ -29,19 +29,3 @@ export const SubheadingComp = ({
|
||||
{subHeading}
|
||||
</p>
|
||||
);
|
||||
|
||||
export const HeadingThreeComp = ({
|
||||
heading,
|
||||
onClick,
|
||||
}: {
|
||||
heading: string;
|
||||
onClick: (event: React.MouseEvent<HTMLParagraphElement, MouseEvent>) => void;
|
||||
}) => (
|
||||
<p
|
||||
onClick={onClick}
|
||||
className="ml-8 mt-2 text-xs cursor-pointer font-medium tracking-tight text-gray-400 hover:text-custom-primary"
|
||||
role="button"
|
||||
>
|
||||
{heading}
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -48,7 +48,7 @@ export const InfoPopover: React.FC<Props> = (props) => {
|
||||
onMouseEnter={() => setIsPopoverOpen(true)}
|
||||
onMouseLeave={() => setIsPopoverOpen(false)}
|
||||
>
|
||||
<button type="button" ref={setReferenceElement} className="block">
|
||||
<button type="button" ref={setReferenceElement} className="block mt-1.5">
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{isPopoverOpen && (
|
||||
|
||||
@@ -1,28 +1,13 @@
|
||||
import { EditorContainer, EditorContentWrapper } from "@plane/editor-core";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { useState } from "react";
|
||||
import { DocumentDetails } from "../types/editor-types";
|
||||
|
||||
type IPageRenderer = {
|
||||
interface IPageRenderer {
|
||||
documentDetails: DocumentDetails;
|
||||
updatePageTitle: (title: string) => Promise<void>;
|
||||
editor: Editor;
|
||||
editorClassNames: string;
|
||||
editorContentCustomClassNames?: string;
|
||||
readonly: boolean;
|
||||
};
|
||||
|
||||
const debounce = (func: (...args: any[]) => void, wait: number) => {
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
return function executedFunction(...args: any[]) {
|
||||
const later = () => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const PageRenderer = (props: IPageRenderer) => {
|
||||
const {
|
||||
@@ -30,35 +15,13 @@ export const PageRenderer = (props: IPageRenderer) => {
|
||||
editor,
|
||||
editorClassNames,
|
||||
editorContentCustomClassNames,
|
||||
updatePageTitle,
|
||||
readonly,
|
||||
} = props;
|
||||
|
||||
const [pageTitle, setPagetitle] = useState(documentDetails.title);
|
||||
|
||||
const debouncedUpdatePageTitle = debounce(updatePageTitle, 300);
|
||||
|
||||
const handlePageTitleChange = (title: string) => {
|
||||
setPagetitle(title);
|
||||
debouncedUpdatePageTitle(title);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full pl-7 pt-5 pb-64">
|
||||
{!readonly ? (
|
||||
<input
|
||||
onChange={(e) => handlePageTitleChange(e.target.value)}
|
||||
className="text-4xl bg-custom-background font-bold break-words pr-5 -mt-2 w-full border-none outline-none"
|
||||
value={pageTitle}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
onChange={(e) => handlePageTitleChange(e.target.value)}
|
||||
className="text-4xl bg-custom-background font-bold break-words pr-5 -mt-2 w-full border-none outline-none overflow-x-clip"
|
||||
value={pageTitle}
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
<h1 className="text-4xl font-bold break-words pr-5 -mt-2">
|
||||
{documentDetails.title}
|
||||
</h1>
|
||||
<div className="flex flex-col h-full w-full pr-5">
|
||||
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
|
||||
<EditorContentWrapper
|
||||
|
||||
@@ -39,8 +39,8 @@ const VerticalDropdownItem = ({
|
||||
export const VerticalDropdownMenu = ({ items }: IVerticalDropdownMenuProps) => {
|
||||
return (
|
||||
<CustomMenu
|
||||
maxHeight={"md"}
|
||||
className={"h-4.5 mt-1"}
|
||||
maxHeight={"lg"}
|
||||
className={"h-4"}
|
||||
placement={"bottom-start"}
|
||||
optionsClassName={
|
||||
"border-custom-border border-r border-solid transition-all duration-200 ease-in-out "
|
||||
|
||||
@@ -1,56 +1,28 @@
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { IssueWidgetExtension } from "./widgets/IssueEmbedWidget";
|
||||
import { SlashCommand } from "@plane/editor-extensions";
|
||||
|
||||
import { IIssueEmbedConfig } from "./widgets/IssueEmbedWidget/types";
|
||||
|
||||
import { SlashCommand, DragAndDrop } from "@plane/editor-extensions";
|
||||
import { ISlashCommandItem, UploadImage } from "@plane/editor-types";
|
||||
import { IssueSuggestions } from "./widgets/IssueEmbedSuggestionList";
|
||||
import { LayersIcon } from "@plane/ui";
|
||||
import { UploadImage } from "@plane/editor-types";
|
||||
import { DragAndDrop } from "@plane/editor-extensions";
|
||||
|
||||
export const DocumentEditorExtensions = (
|
||||
uploadFile: UploadImage,
|
||||
issueEmbedConfig?: IIssueEmbedConfig,
|
||||
setIsSubmitting?: (
|
||||
isSubmitting: "submitting" | "submitted" | "saved",
|
||||
) => void,
|
||||
) => {
|
||||
const additonalOptions: ISlashCommandItem[] = [
|
||||
{
|
||||
title: "Issue Embed",
|
||||
description: "Embed an issue from the project",
|
||||
searchTerms: ["Issue", "Iss"],
|
||||
icon: <LayersIcon height={"20px"} width={"20px"} />,
|
||||
command: ({ editor, range }) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(
|
||||
range,
|
||||
"<p class='text-sm bg-gray-300 w-fit pl-3 pr-3 pt-1 pb-1 rounded shadow-sm'>#issue_</p>",
|
||||
)
|
||||
.run();
|
||||
},
|
||||
) => [
|
||||
SlashCommand(uploadFile, setIsSubmitting),
|
||||
DragAndDrop,
|
||||
Placeholder.configure({
|
||||
placeholder: ({ node }) => {
|
||||
if (node.type.name === "heading") {
|
||||
return `Heading ${node.attrs.level}`;
|
||||
}
|
||||
if (node.type.name === "image" || node.type.name === "table") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return "Press '/' for commands...";
|
||||
},
|
||||
];
|
||||
|
||||
return [
|
||||
SlashCommand(uploadFile, setIsSubmitting, additonalOptions),
|
||||
DragAndDrop,
|
||||
Placeholder.configure({
|
||||
placeholder: ({ node }) => {
|
||||
if (node.type.name === "heading") {
|
||||
return `Heading ${node.attrs.level}`;
|
||||
}
|
||||
if (node.type.name === "image" || node.type.name === "table") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return "Press '/' for commands...";
|
||||
},
|
||||
includeChildren: true,
|
||||
}),
|
||||
IssueWidgetExtension({ issueEmbedConfig }),
|
||||
IssueSuggestions(issueEmbedConfig ? issueEmbedConfig.issues : []),
|
||||
];
|
||||
};
|
||||
includeChildren: true,
|
||||
}),
|
||||
];
|
||||
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
import { Editor, Range } from "@tiptap/react";
|
||||
import { IssueEmbedSuggestions } from "./issue-suggestion-extension";
|
||||
import { getIssueSuggestionItems } from "./issue-suggestion-items";
|
||||
import { IssueListRenderer } from "./issue-suggestion-renderer";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
export type CommandProps = {
|
||||
editor: Editor;
|
||||
range: Range;
|
||||
};
|
||||
|
||||
export interface IIssueListSuggestion {
|
||||
title: string;
|
||||
priority: "high" | "low" | "medium" | "urgent";
|
||||
identifier: string;
|
||||
state: "Cancelled" | "In Progress" | "Todo" | "Done" | "Backlog";
|
||||
command: ({ editor, range }: CommandProps) => void;
|
||||
}
|
||||
|
||||
export const IssueSuggestions = (suggestions: any[]) => {
|
||||
const mappedSuggestions: IIssueListSuggestion[] = suggestions.map(
|
||||
(suggestion): IIssueListSuggestion => {
|
||||
let transactionId = uuidv4();
|
||||
return {
|
||||
title: suggestion.name,
|
||||
priority: suggestion.priority.toString(),
|
||||
identifier: `${suggestion.project_detail.identifier}-${suggestion.sequence_id}`,
|
||||
state: suggestion.state_detail.name,
|
||||
command: ({ editor, range }) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(range, {
|
||||
type: "issue-embed-component",
|
||||
attrs: {
|
||||
entity_identifier: suggestion.id,
|
||||
id: transactionId,
|
||||
title: suggestion.name,
|
||||
project_identifier: suggestion.project_detail.identifier,
|
||||
sequence_id: suggestion.sequence_id,
|
||||
entity_name: "issue",
|
||||
},
|
||||
})
|
||||
.run();
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return IssueEmbedSuggestions.configure({
|
||||
suggestion: {
|
||||
items: getIssueSuggestionItems(mappedSuggestions),
|
||||
render: IssueListRenderer,
|
||||
},
|
||||
});
|
||||
};
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
import { Extension, Range } from "@tiptap/core";
|
||||
import { PluginKey } from "@tiptap/pm/state";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import Suggestion from "@tiptap/suggestion";
|
||||
|
||||
export const IssueEmbedSuggestions = Extension.create({
|
||||
name: "issue-embed-suggestions",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
suggestion: {
|
||||
command: ({
|
||||
editor,
|
||||
range,
|
||||
props,
|
||||
}: {
|
||||
editor: Editor;
|
||||
range: Range;
|
||||
props: any;
|
||||
}) => {
|
||||
props.command({ editor, range });
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
char: "#issue_",
|
||||
pluginKey: new PluginKey("issue-embed-suggestions"),
|
||||
editor: this.editor,
|
||||
allowSpaces: true,
|
||||
|
||||
...this.options.suggestion,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
import { IIssueListSuggestion } from ".";
|
||||
|
||||
export const getIssueSuggestionItems = (
|
||||
issueSuggestions: Array<IIssueListSuggestion>,
|
||||
) => {
|
||||
return ({ query }: { query: string }) => {
|
||||
const search = query.toLowerCase();
|
||||
const filteredSuggestions = issueSuggestions.filter((item) => {
|
||||
return (
|
||||
item.title.toLowerCase().includes(search) ||
|
||||
item.identifier.toLowerCase().includes(search) ||
|
||||
item.priority.toLowerCase().includes(search)
|
||||
);
|
||||
});
|
||||
|
||||
return filteredSuggestions;
|
||||
};
|
||||
};
|
||||
-279
@@ -1,279 +0,0 @@
|
||||
import { cn } from "@plane/editor-core";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import tippy from "tippy.js";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { PriorityIcon } from "@plane/ui";
|
||||
|
||||
const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
|
||||
const containerHeight = container.offsetHeight;
|
||||
const itemHeight = item ? item.offsetHeight : 0;
|
||||
|
||||
const top = item.offsetTop;
|
||||
const bottom = top + itemHeight;
|
||||
|
||||
if (top < container.scrollTop) {
|
||||
// container.scrollTop = top - containerHeight;
|
||||
item.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
} else if (bottom > containerHeight + container.scrollTop) {
|
||||
// container.scrollTop = bottom - containerHeight;
|
||||
item.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
};
|
||||
interface IssueSuggestionProps {
|
||||
title: string;
|
||||
priority: "high" | "low" | "medium" | "urgent" | "none";
|
||||
state: "Cancelled" | "In Progress" | "Todo" | "Done" | "Backlog";
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
const IssueSuggestionList = ({
|
||||
items,
|
||||
command,
|
||||
editor,
|
||||
}: {
|
||||
items: IssueSuggestionProps[];
|
||||
command: any;
|
||||
editor: Editor;
|
||||
range: any;
|
||||
}) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [currentSection, setCurrentSection] = useState<string>("Backlog");
|
||||
const sections = ["Backlog", "In Progress", "Todo", "Done", "Cancelled"];
|
||||
const [displayedItems, setDisplayedItems] = useState<{
|
||||
[key: string]: IssueSuggestionProps[];
|
||||
}>({});
|
||||
const [displayedTotalLength, setDisplayedTotalLength] = useState(0);
|
||||
const commandListContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let newDisplayedItems: { [key: string]: IssueSuggestionProps[] } = {};
|
||||
let totalLength = 0;
|
||||
sections.forEach((section) => {
|
||||
newDisplayedItems[section] = items
|
||||
.filter((item) => item.state === section)
|
||||
.slice(0, 5);
|
||||
|
||||
totalLength += newDisplayedItems[section].length;
|
||||
});
|
||||
setDisplayedTotalLength(totalLength);
|
||||
setDisplayedItems(newDisplayedItems);
|
||||
}, [items]);
|
||||
|
||||
const selectItem = useCallback(
|
||||
(index: number) => {
|
||||
const item = displayedItems[currentSection][index];
|
||||
if (item) {
|
||||
command(item);
|
||||
}
|
||||
},
|
||||
[command, displayedItems, currentSection],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter", "Tab"];
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (navigationKeys.includes(e.key)) {
|
||||
e.preventDefault();
|
||||
// if (editor.isFocused) {
|
||||
// editor.chain().blur();
|
||||
// commandListContainer.current?.focus();
|
||||
// }
|
||||
if (e.key === "ArrowUp") {
|
||||
setSelectedIndex(
|
||||
(selectedIndex + displayedItems[currentSection].length - 1) %
|
||||
displayedItems[currentSection].length,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
const nextIndex =
|
||||
(selectedIndex + 1) % displayedItems[currentSection].length;
|
||||
setSelectedIndex(nextIndex);
|
||||
if (nextIndex === 4) {
|
||||
const nextItems = items
|
||||
.filter((item) => item.state === currentSection)
|
||||
.slice(
|
||||
displayedItems[currentSection].length,
|
||||
displayedItems[currentSection].length + 5,
|
||||
);
|
||||
setDisplayedItems((prevItems) => ({
|
||||
...prevItems,
|
||||
[currentSection]: [...prevItems[currentSection], ...nextItems],
|
||||
}));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
selectItem(selectedIndex);
|
||||
return true;
|
||||
}
|
||||
if (e.key === "Tab") {
|
||||
const currentSectionIndex = sections.indexOf(currentSection);
|
||||
const nextSectionIndex = (currentSectionIndex + 1) % sections.length;
|
||||
setCurrentSection(sections[nextSectionIndex]);
|
||||
setSelectedIndex(0);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} else if (e.key === "Escape") {
|
||||
if (!editor.isFocused) {
|
||||
editor.chain().focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [
|
||||
displayedItems,
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
selectItem,
|
||||
currentSection,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = commandListContainer?.current;
|
||||
if (container) {
|
||||
const sectionContainer = container?.querySelector(
|
||||
`#${currentSection}-container`,
|
||||
) as HTMLDivElement;
|
||||
if (sectionContainer) {
|
||||
updateScrollView(container, sectionContainer);
|
||||
}
|
||||
const sectionScrollContainer = container?.querySelector(
|
||||
`#${currentSection}`,
|
||||
) as HTMLElement;
|
||||
const item = sectionScrollContainer?.children[
|
||||
selectedIndex
|
||||
] as HTMLElement;
|
||||
if (item && sectionScrollContainer) {
|
||||
updateScrollView(sectionScrollContainer, item);
|
||||
}
|
||||
}
|
||||
}, [selectedIndex, currentSection]);
|
||||
|
||||
return displayedTotalLength > 0 ? (
|
||||
<div
|
||||
id="issue-list-container"
|
||||
ref={commandListContainer}
|
||||
className="z-[10] fixed max-h-80 w-60 overflow-y-auto overflow-x-hidden rounded-md border border-custom-border-100 bg-custom-background-100 px-1 shadow-custom-shadow-xs transition-all"
|
||||
>
|
||||
{sections.map((section) => {
|
||||
const sectionItems = displayedItems[section];
|
||||
return (
|
||||
sectionItems &&
|
||||
sectionItems.length > 0 && (
|
||||
<div
|
||||
className={"h-full w-full flex flex-col"}
|
||||
key={`${section}-container`}
|
||||
id={`${section}-container`}
|
||||
>
|
||||
<h6
|
||||
className={
|
||||
"sticky top-0 z-[10] bg-custom-background-100 text-xs text-custom-text-400 font-medium px-2 py-1"
|
||||
}
|
||||
>
|
||||
{section}
|
||||
</h6>
|
||||
<div
|
||||
key={section}
|
||||
id={section}
|
||||
className={"max-h-[140px] overflow-y-scroll overflow-x-hidden"}
|
||||
>
|
||||
{sectionItems.map(
|
||||
(item: IssueSuggestionProps, index: number) => (
|
||||
<button
|
||||
className={cn(
|
||||
`flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100`,
|
||||
{
|
||||
"bg-custom-primary-100/5 text-custom-text-100":
|
||||
section === currentSection &&
|
||||
index === selectedIndex,
|
||||
},
|
||||
)}
|
||||
key={index}
|
||||
onClick={() => selectItem(index)}
|
||||
>
|
||||
<h5 className="text-xs text-custom-text-300 whitespace-nowrap">
|
||||
{item.identifier}
|
||||
</h5>
|
||||
<PriorityIcon priority={item.priority} />
|
||||
<div>
|
||||
<p className="flex-grow text-xs truncate">
|
||||
{item.title}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export const IssueListRenderer = () => {
|
||||
let component: ReactRenderer | null = null;
|
||||
let popup: any | null = null;
|
||||
|
||||
return {
|
||||
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
component = new ReactRenderer(IssueSuggestionList, {
|
||||
props,
|
||||
// @ts-ignore
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.querySelector("#editor-container"),
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "right",
|
||||
});
|
||||
},
|
||||
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
component?.updateProps(props);
|
||||
|
||||
popup &&
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||
if (props.event.key === "Escape") {
|
||||
popup?.[0].hide();
|
||||
return true;
|
||||
}
|
||||
// @ts-ignore
|
||||
return component?.ref?.onKeyDown(props);
|
||||
},
|
||||
onExit: (e) => {
|
||||
popup?.[0].destroy();
|
||||
setTimeout(() => {
|
||||
component?.destroy();
|
||||
}, 300);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
import { IssueWidget } from "./issue-widget-node";
|
||||
import { IIssueEmbedConfig } from "./types";
|
||||
|
||||
interface IssueWidgetExtensionProps {
|
||||
issueEmbedConfig?: IIssueEmbedConfig;
|
||||
}
|
||||
|
||||
export const IssueWidgetExtension = ({
|
||||
issueEmbedConfig,
|
||||
}: IssueWidgetExtensionProps) => IssueWidget.configure({
|
||||
issueEmbedConfig,
|
||||
});
|
||||
-89
@@ -1,89 +0,0 @@
|
||||
// @ts-nocheck
|
||||
import { useState, useEffect } from "react";
|
||||
import { NodeViewWrapper } from "@tiptap/react";
|
||||
import { Avatar, AvatarGroup, Loader, PriorityIcon } from "@plane/ui";
|
||||
import { Calendar, AlertTriangle } from "lucide-react";
|
||||
|
||||
const IssueWidgetCard = (props) => {
|
||||
const [loading, setLoading] = useState<number>(1);
|
||||
const [issueDetails, setIssueDetails] = useState();
|
||||
|
||||
useEffect(() => {
|
||||
props.issueEmbedConfig
|
||||
.fetchIssue(props.node.attrs.entity_identifier)
|
||||
.then((issue) => {
|
||||
setIssueDetails(issue);
|
||||
setLoading(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
setLoading(-1);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const completeIssueEmbedAction = () => {
|
||||
props.issueEmbedConfig.clickAction(issueDetails.id, props.node.attrs.title);
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="issue-embed-component m-2">
|
||||
{loading == 0 ? (
|
||||
<div
|
||||
onClick={completeIssueEmbedAction}
|
||||
className="cursor-pointer w-full space-y-2 border-[0.5px] border-custom-border-200 rounded-md p-3 shadow-custom-shadow-2xs"
|
||||
>
|
||||
<h5 className="text-xs text-custom-text-300">
|
||||
{issueDetails.project_detail.identifier}-{issueDetails.sequence_id}
|
||||
</h5>
|
||||
<h4 className="break-words text-sm font-medium">
|
||||
{issueDetails.name}
|
||||
</h4>
|
||||
<div className="flex items-center flex-wrap gap-x-3 gap-y-2">
|
||||
<div>
|
||||
<PriorityIcon priority={issueDetails.priority} />
|
||||
</div>
|
||||
<div>
|
||||
<AvatarGroup size="sm">
|
||||
{issueDetails.assignee_details.map((assignee) => {
|
||||
return (
|
||||
<Avatar
|
||||
key={assignee.id}
|
||||
name={assignee.display_name}
|
||||
src={assignee.avatar}
|
||||
className={"m-0"}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</AvatarGroup>
|
||||
</div>
|
||||
{issueDetails.target_date && (
|
||||
<div className="rounded flex px-2.5 py-1 items-center border-[0.5px] border-custom-border-300 gap-1 text-custom-text-100 text-xs h-5">
|
||||
<Calendar className="h-3 w-3" strokeWidth={1.5} />
|
||||
{new Date(issueDetails.target_date).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : loading == -1 ? (
|
||||
<div className="flex gap-[8px] items-center pb-[10px] pt-[10px] pl-[13px] rounded border-[#D97706] border-2 bg-[#FFFBEB] text-[#D97706]">
|
||||
<AlertTriangle color={"#D97706"} />
|
||||
{
|
||||
"This Issue embed is not found in any project. It can no longer be updated or accessed from here."
|
||||
}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full space-y-2 border-[0.5px] border-custom-border-200 rounded-md p-3 shadow-custom-shadow-2xs">
|
||||
<Loader className={"px-6"}>
|
||||
<Loader.Item height={"30px"} />
|
||||
<div className={"space-y-2 mt-3"}>
|
||||
<Loader.Item height={"20px"} width={"70%"} />
|
||||
<Loader.Item height={"20px"} width={"60%"} />
|
||||
</div>
|
||||
</Loader>
|
||||
</div>
|
||||
)}
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default IssueWidgetCard;
|
||||
-68
@@ -1,68 +0,0 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
import IssueWidgetCard from "./issue-widget-card";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
|
||||
export const IssueWidget = Node.create({
|
||||
name: "issue-embed-component",
|
||||
group: "block",
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
id: {
|
||||
default: null,
|
||||
},
|
||||
class: {
|
||||
default: "w-[600px]",
|
||||
},
|
||||
title: {
|
||||
default: null,
|
||||
},
|
||||
entity_name: {
|
||||
default: null,
|
||||
},
|
||||
entity_identifier: {
|
||||
default: null,
|
||||
},
|
||||
project_identifier: {
|
||||
default: null,
|
||||
},
|
||||
sequence_id: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer((props: Object) => (
|
||||
<IssueWidgetCard
|
||||
{...props}
|
||||
issueEmbedConfig={this.options.issueEmbedConfig}
|
||||
/>
|
||||
));
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "issue-embed-component",
|
||||
getAttrs: (node: string | HTMLElement) => {
|
||||
if (typeof node === "string") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: node.getAttribute("id") || "",
|
||||
title: node.getAttribute("title") || "",
|
||||
entity_name: node.getAttribute("entity_name") || "",
|
||||
entity_identifier: node.getAttribute("entity_identifier") || "",
|
||||
project_identifier: node.getAttribute("project_identifier") || "",
|
||||
sequence_id: node.getAttribute("sequence_id") || "",
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["issue-embed-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
export interface IEmbedConfig {
|
||||
issueEmbedConfig: IIssueEmbedConfig;
|
||||
}
|
||||
|
||||
export interface IIssueEmbedConfig {
|
||||
fetchIssue: (issueId: string) => Promise<any>;
|
||||
clickAction: (issueId: string, issueTitle: string) => void;
|
||||
issues: Array<any>;
|
||||
}
|
||||
@@ -10,26 +10,18 @@ export const useEditorMarkings = () => {
|
||||
const tempMarkings: IMarking[] = [];
|
||||
let h1Sequence: number = 0;
|
||||
let h2Sequence: number = 0;
|
||||
let h3Sequence: number = 0;
|
||||
if (nodes) {
|
||||
nodes.forEach((node) => {
|
||||
if (
|
||||
node.type === "heading" &&
|
||||
(node.attrs.level === 1 ||
|
||||
node.attrs.level === 2 ||
|
||||
node.attrs.level === 3) &&
|
||||
(node.attrs.level === 1 || node.attrs.level === 2) &&
|
||||
node.content
|
||||
) {
|
||||
tempMarkings.push({
|
||||
type: "heading",
|
||||
level: node.attrs.level,
|
||||
text: node.content[0].text,
|
||||
sequence:
|
||||
node.attrs.level === 1
|
||||
? ++h1Sequence
|
||||
: node.attrs.level === 2
|
||||
? ++h2Sequence
|
||||
: ++h3Sequence,
|
||||
sequence: node.attrs.level === 1 ? ++h1Sequence : ++h2Sequence,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -14,30 +14,14 @@ import { DocumentDetails } from "./types/editor-types";
|
||||
import { PageRenderer } from "./components/page-renderer";
|
||||
import { getMenuOptions } from "./utils/menu-options";
|
||||
import { useRouter } from "next/router";
|
||||
import { IEmbedConfig } from "./extensions/widgets/IssueEmbedWidget/types";
|
||||
import { UploadImage, DeleteImage, RestoreImage } from "@plane/editor-types";
|
||||
|
||||
interface IDocumentEditor {
|
||||
// document info
|
||||
documentDetails: DocumentDetails;
|
||||
value: string;
|
||||
rerenderOnPropsChange: {
|
||||
id: string;
|
||||
description_html: string;
|
||||
};
|
||||
|
||||
// file operations
|
||||
uploadFile: UploadImage;
|
||||
deleteFile: DeleteImage;
|
||||
restoreFile: RestoreImage;
|
||||
cancelUploadImage: () => any;
|
||||
|
||||
// editor state managers
|
||||
onActionCompleteHandler: (action: {
|
||||
title: string;
|
||||
message: string;
|
||||
type: "success" | "error" | "warning" | "info";
|
||||
}) => void;
|
||||
customClassName?: string;
|
||||
editorContentCustomClassNames?: string;
|
||||
onChange: (json: any, html: string) => void;
|
||||
@@ -46,15 +30,10 @@ interface IDocumentEditor {
|
||||
) => void;
|
||||
setShouldShowAlert?: (showAlert: boolean) => void;
|
||||
forwardedRef?: any;
|
||||
updatePageTitle: (title: string) => Promise<void>;
|
||||
debouncedUpdatesEnabled?: boolean;
|
||||
isSubmitting: "submitting" | "submitted" | "saved";
|
||||
|
||||
// embed configuration
|
||||
duplicationConfig?: IDuplicationConfig;
|
||||
pageLockConfig?: IPageLockConfig;
|
||||
pageArchiveConfig?: IPageArchiveConfig;
|
||||
embedConfig?: IEmbedConfig;
|
||||
}
|
||||
interface DocumentEditorProps extends IDocumentEditor {
|
||||
forwardedRef?: React.Ref<EditorHandle>;
|
||||
@@ -83,17 +62,11 @@ const DocumentEditor = ({
|
||||
uploadFile,
|
||||
deleteFile,
|
||||
restoreFile,
|
||||
isSubmitting,
|
||||
customClassName,
|
||||
forwardedRef,
|
||||
duplicationConfig,
|
||||
pageLockConfig,
|
||||
pageArchiveConfig,
|
||||
embedConfig,
|
||||
updatePageTitle,
|
||||
cancelUploadImage,
|
||||
onActionCompleteHandler,
|
||||
rerenderOnPropsChange,
|
||||
}: IDocumentEditor) => {
|
||||
// const [alert, setAlert] = useState<string>("")
|
||||
const { markings, updateMarkings } = useEditorMarkings();
|
||||
@@ -115,14 +88,8 @@ const DocumentEditor = ({
|
||||
value,
|
||||
uploadFile,
|
||||
deleteFile,
|
||||
cancelUploadImage,
|
||||
rerenderOnPropsChange,
|
||||
forwardedRef,
|
||||
extensions: DocumentEditorExtensions(
|
||||
uploadFile,
|
||||
embedConfig?.issueEmbedConfig,
|
||||
setIsSubmitting,
|
||||
),
|
||||
extensions: DocumentEditorExtensions(uploadFile, setIsSubmitting),
|
||||
});
|
||||
|
||||
if (!editor) {
|
||||
@@ -135,9 +102,7 @@ const DocumentEditor = ({
|
||||
duplicationConfig: duplicationConfig,
|
||||
pageLockConfig: pageLockConfig,
|
||||
pageArchiveConfig: pageArchiveConfig,
|
||||
onActionCompleteHandler,
|
||||
});
|
||||
|
||||
const editorClassNames = getEditorClassNames({
|
||||
noBorder: true,
|
||||
borderOnFocus: false,
|
||||
@@ -161,7 +126,6 @@ const DocumentEditor = ({
|
||||
isArchived={!pageArchiveConfig ? false : pageArchiveConfig.is_archived}
|
||||
archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at}
|
||||
documentDetails={documentDetails}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
<div className="h-full w-full flex overflow-y-auto">
|
||||
<div className="flex-shrink-0 h-full w-56 lg:w-72 sticky top-0">
|
||||
@@ -173,12 +137,10 @@ const DocumentEditor = ({
|
||||
</div>
|
||||
<div className="h-full w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)]">
|
||||
<PageRenderer
|
||||
readonly={false}
|
||||
editor={editor}
|
||||
editorContentCustomClassNames={editorContentCustomClassNames}
|
||||
editorClassNames={editorClassNames}
|
||||
documentDetails={documentDetails}
|
||||
updatePageTitle={updatePageTitle}
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden lg:block flex-shrink-0 w-56 lg:w-72" />
|
||||
|
||||
@@ -4,8 +4,6 @@ import { useState, forwardRef, useEffect } from "react";
|
||||
import { EditorHeader } from "../components/editor-header";
|
||||
import { PageRenderer } from "../components/page-renderer";
|
||||
import { SummarySideBar } from "../components/summary-side-bar";
|
||||
import { IssueWidgetExtension } from "../extensions/widgets/IssueEmbedWidget";
|
||||
import { IEmbedConfig } from "../extensions/widgets/IssueEmbedWidget/types";
|
||||
import { useEditorMarkings } from "../hooks/use-editor-markings";
|
||||
import { DocumentDetails } from "../types/editor-types";
|
||||
import {
|
||||
@@ -17,10 +15,6 @@ import { getMenuOptions } from "../utils/menu-options";
|
||||
|
||||
interface IDocumentReadOnlyEditor {
|
||||
value: string;
|
||||
rerenderOnPropsChange?: {
|
||||
id: string;
|
||||
description_html: string;
|
||||
};
|
||||
noBorder: boolean;
|
||||
borderOnFocus: boolean;
|
||||
customClassName: string;
|
||||
@@ -28,12 +22,6 @@ interface IDocumentReadOnlyEditor {
|
||||
pageLockConfig?: IPageLockConfig;
|
||||
pageArchiveConfig?: IPageArchiveConfig;
|
||||
pageDuplicationConfig?: IDuplicationConfig;
|
||||
onActionCompleteHandler: (action: {
|
||||
title: string;
|
||||
message: string;
|
||||
type: "success" | "error" | "warning" | "info";
|
||||
}) => void;
|
||||
embedConfig?: IEmbedConfig;
|
||||
}
|
||||
|
||||
interface DocumentReadOnlyEditorProps extends IDocumentReadOnlyEditor {
|
||||
@@ -55,9 +43,6 @@ const DocumentReadOnlyEditor = ({
|
||||
pageDuplicationConfig,
|
||||
pageLockConfig,
|
||||
pageArchiveConfig,
|
||||
embedConfig,
|
||||
rerenderOnPropsChange,
|
||||
onActionCompleteHandler,
|
||||
}: DocumentReadOnlyEditorProps) => {
|
||||
const router = useRouter();
|
||||
const [sidePeekVisible, setSidePeekVisible] = useState(true);
|
||||
@@ -66,17 +51,13 @@ const DocumentReadOnlyEditor = ({
|
||||
const editor = useReadOnlyEditor({
|
||||
value,
|
||||
forwardedRef,
|
||||
rerenderOnPropsChange,
|
||||
extensions: [
|
||||
IssueWidgetExtension({ issueEmbedConfig: embedConfig?.issueEmbedConfig }),
|
||||
],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
updateMarkings(editor.getJSON());
|
||||
}
|
||||
}, [editor]);
|
||||
}, [editor?.getJSON()]);
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
@@ -94,7 +75,6 @@ const DocumentReadOnlyEditor = ({
|
||||
pageArchiveConfig: pageArchiveConfig,
|
||||
pageLockConfig: pageLockConfig,
|
||||
duplicationConfig: pageDuplicationConfig,
|
||||
onActionCompleteHandler,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -121,8 +101,6 @@ const DocumentReadOnlyEditor = ({
|
||||
</div>
|
||||
<div className="h-full w-full">
|
||||
<PageRenderer
|
||||
updatePageTitle={() => Promise.resolve()}
|
||||
readonly={true}
|
||||
editor={editor}
|
||||
editorClassNames={editorClassNames}
|
||||
documentDetails={documentDetails}
|
||||
|
||||
@@ -25,11 +25,6 @@ export interface MenuOptionsProps {
|
||||
duplicationConfig?: IDuplicationConfig;
|
||||
pageLockConfig?: IPageLockConfig;
|
||||
pageArchiveConfig?: IPageArchiveConfig;
|
||||
onActionCompleteHandler: (action: {
|
||||
title: string;
|
||||
message: string;
|
||||
type: "success" | "error" | "warning" | "info";
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export const getMenuOptions = ({
|
||||
@@ -38,21 +33,13 @@ export const getMenuOptions = ({
|
||||
duplicationConfig,
|
||||
pageLockConfig,
|
||||
pageArchiveConfig,
|
||||
onActionCompleteHandler,
|
||||
}: MenuOptionsProps) => {
|
||||
const KanbanMenuOptions: IVerticalDropdownItemProps[] = [
|
||||
{
|
||||
key: 1,
|
||||
type: "copy_markdown",
|
||||
Icon: ClipboardIcon,
|
||||
action: () => {
|
||||
onActionCompleteHandler({
|
||||
title: "Markdown Copied",
|
||||
message: "Page Copied as Markdown",
|
||||
type: "success",
|
||||
});
|
||||
copyMarkdownToClipboard(editor);
|
||||
},
|
||||
action: () => copyMarkdownToClipboard(editor),
|
||||
label: "Copy markdown",
|
||||
},
|
||||
// {
|
||||
@@ -66,14 +53,7 @@ export const getMenuOptions = ({
|
||||
key: 3,
|
||||
type: "copy_page_link",
|
||||
Icon: Link,
|
||||
action: () => {
|
||||
onActionCompleteHandler({
|
||||
title: "Link Copied",
|
||||
message: "Link to the page has been copied to clipboard",
|
||||
type: "success",
|
||||
});
|
||||
CopyPageLink();
|
||||
},
|
||||
action: () => CopyPageLink(),
|
||||
label: "Copy page link",
|
||||
},
|
||||
];
|
||||
@@ -84,25 +64,7 @@ export const getMenuOptions = ({
|
||||
key: KanbanMenuOptions.length++,
|
||||
type: "duplicate_page",
|
||||
Icon: Copy,
|
||||
action: () => {
|
||||
duplicationConfig
|
||||
.action()
|
||||
.then(() => {
|
||||
onActionCompleteHandler({
|
||||
title: "Page Copied",
|
||||
message:
|
||||
"Page has been copied as 'Copy of' followed by page title",
|
||||
type: "success",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
onActionCompleteHandler({
|
||||
title: "Copy Failed",
|
||||
message: "Sorry, page cannot be copied, please try again later.",
|
||||
type: "error",
|
||||
});
|
||||
});
|
||||
},
|
||||
action: duplicationConfig.action,
|
||||
label: "Make a copy",
|
||||
});
|
||||
}
|
||||
@@ -113,25 +75,7 @@ export const getMenuOptions = ({
|
||||
type: pageLockConfig.is_locked ? "unlock_page" : "lock_page",
|
||||
Icon: pageLockConfig.is_locked ? Unlock : Lock,
|
||||
label: pageLockConfig.is_locked ? "Unlock page" : "Lock page",
|
||||
action: () => {
|
||||
const state = pageLockConfig.is_locked ? "Unlocked" : "Locked";
|
||||
pageLockConfig
|
||||
.action()
|
||||
.then(() => {
|
||||
onActionCompleteHandler({
|
||||
title: `Page ${state}`,
|
||||
message: `Page has been ${state}, no one will be able to change the state of lock except you.`,
|
||||
type: "success",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
onActionCompleteHandler({
|
||||
title: `Page cannot be ${state}`,
|
||||
message: `Sorry, page cannot be ${state}, please try again later`,
|
||||
type: "error",
|
||||
});
|
||||
});
|
||||
},
|
||||
action: pageLockConfig.action,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -142,25 +86,7 @@ export const getMenuOptions = ({
|
||||
type: pageArchiveConfig.is_archived ? "unarchive_page" : "archive_page",
|
||||
Icon: pageArchiveConfig.is_archived ? ArchiveRestoreIcon : Archive,
|
||||
label: pageArchiveConfig.is_archived ? "Restore page" : "Archive page",
|
||||
action: () => {
|
||||
const state = pageArchiveConfig.is_archived ? "Unarchived" : "Archived";
|
||||
pageArchiveConfig
|
||||
.action()
|
||||
.then(() => {
|
||||
onActionCompleteHandler({
|
||||
title: `Page ${state}`,
|
||||
message: `Page has been ${state}, you can checkout all archived tab and can restore the page later.`,
|
||||
type: "success",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
onActionCompleteHandler({
|
||||
title: `Page cannot be ${state}`,
|
||||
message: `Sorry, page cannot be ${state}, please try again later.`,
|
||||
type: "success",
|
||||
});
|
||||
});
|
||||
},
|
||||
action: pageArchiveConfig.action,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -41,8 +41,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "18.15.3",
|
||||
"@types/react": "^18.2.42",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@types/react": "^18.2.39",
|
||||
"@types/react-dom": "^18.2.14",
|
||||
"eslint": "^7.32.0",
|
||||
"postcss": "^8.4.29",
|
||||
"tailwind-config-custom": "*",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { PluginKey, NodeSelection, Plugin } from "@tiptap/pm/state";
|
||||
import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
|
||||
|
||||
function createDragHandleElement(): HTMLElement {
|
||||
const dragHandleElement = document.createElement("div");
|
||||
let dragHandleElement = document.createElement("div");
|
||||
dragHandleElement.draggable = true;
|
||||
dragHandleElement.dataset.dragHandle = "";
|
||||
dragHandleElement.classList.add("drag-handle");
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Editor, Range, Extension } from "@tiptap/core";
|
||||
import Suggestion from "@tiptap/suggestion";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import tippy from "tippy.js";
|
||||
import type { UploadImage, ISlashCommandItem, CommandProps } from "@plane/editor-types";
|
||||
import type { UploadImage } from "@plane/editor-types";
|
||||
import {
|
||||
Heading1,
|
||||
Heading2,
|
||||
@@ -44,6 +44,11 @@ interface CommandItemProps {
|
||||
icon: ReactNode;
|
||||
}
|
||||
|
||||
interface CommandProps {
|
||||
editor: Editor;
|
||||
range: Range;
|
||||
}
|
||||
|
||||
const Command = Extension.create({
|
||||
name: "slash-command",
|
||||
addOptions() {
|
||||
@@ -83,146 +88,134 @@ const getSuggestionItems =
|
||||
setIsSubmitting?: (
|
||||
isSubmitting: "submitting" | "submitted" | "saved",
|
||||
) => void,
|
||||
additonalOptions?: Array<ISlashCommandItem>
|
||||
) =>
|
||||
({ query }: { query: string }) => {
|
||||
let slashCommands: ISlashCommandItem[] = [
|
||||
{
|
||||
title: "Text",
|
||||
description: "Just start typing with plain text.",
|
||||
searchTerms: ["p", "paragraph"],
|
||||
icon: <Text size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.toggleNode("paragraph", "paragraph")
|
||||
.run();
|
||||
},
|
||||
({ query }: { query: string }) =>
|
||||
[
|
||||
{
|
||||
title: "Text",
|
||||
description: "Just start typing with plain text.",
|
||||
searchTerms: ["p", "paragraph"],
|
||||
icon: <Text size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.toggleNode("paragraph", "paragraph")
|
||||
.run();
|
||||
},
|
||||
{
|
||||
title: "Heading 1",
|
||||
description: "Big section heading.",
|
||||
searchTerms: ["title", "big", "large"],
|
||||
icon: <Heading1 size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleHeadingOne(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Heading 1",
|
||||
description: "Big section heading.",
|
||||
searchTerms: ["title", "big", "large"],
|
||||
icon: <Heading1 size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleHeadingOne(editor, range);
|
||||
},
|
||||
{
|
||||
title: "Heading 2",
|
||||
description: "Medium section heading.",
|
||||
searchTerms: ["subtitle", "medium"],
|
||||
icon: <Heading2 size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleHeadingTwo(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Heading 2",
|
||||
description: "Medium section heading.",
|
||||
searchTerms: ["subtitle", "medium"],
|
||||
icon: <Heading2 size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleHeadingTwo(editor, range);
|
||||
},
|
||||
{
|
||||
title: "Heading 3",
|
||||
description: "Small section heading.",
|
||||
searchTerms: ["subtitle", "small"],
|
||||
icon: <Heading3 size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleHeadingThree(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Heading 3",
|
||||
description: "Small section heading.",
|
||||
searchTerms: ["subtitle", "small"],
|
||||
icon: <Heading3 size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleHeadingThree(editor, range);
|
||||
},
|
||||
{
|
||||
title: "To-do List",
|
||||
description: "Track tasks with a to-do list.",
|
||||
searchTerms: ["todo", "task", "list", "check", "checkbox"],
|
||||
icon: <CheckSquare size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleTaskList(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "To-do List",
|
||||
description: "Track tasks with a to-do list.",
|
||||
searchTerms: ["todo", "task", "list", "check", "checkbox"],
|
||||
icon: <CheckSquare size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleTaskList(editor, range);
|
||||
},
|
||||
{
|
||||
title: "Bullet List",
|
||||
description: "Create a simple bullet list.",
|
||||
searchTerms: ["unordered", "point"],
|
||||
icon: <List size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleBulletList(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Bullet List",
|
||||
description: "Create a simple bullet list.",
|
||||
searchTerms: ["unordered", "point"],
|
||||
icon: <List size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleBulletList(editor, range);
|
||||
},
|
||||
{
|
||||
title: "Divider",
|
||||
description: "Visually divide blocks",
|
||||
searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
|
||||
icon: <MinusSquare size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
// @ts-expect-error I have to move this to the core
|
||||
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Divider",
|
||||
description: "Visually divide blocks",
|
||||
searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
|
||||
icon: <MinusSquare size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
// @ts-expect-error I have to move this to the core
|
||||
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
|
||||
},
|
||||
{
|
||||
title: "Table",
|
||||
description: "Create a Table",
|
||||
searchTerms: ["table", "cell", "db", "data", "tabular"],
|
||||
icon: <Table size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
insertTableCommand(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Table",
|
||||
description: "Create a Table",
|
||||
searchTerms: ["table", "cell", "db", "data", "tabular"],
|
||||
icon: <Table size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
insertTableCommand(editor, range);
|
||||
},
|
||||
{
|
||||
title: "Numbered List",
|
||||
description: "Create a list with numbering.",
|
||||
searchTerms: ["ordered"],
|
||||
icon: <ListOrdered size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleOrderedList(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Numbered List",
|
||||
description: "Create a list with numbering.",
|
||||
searchTerms: ["ordered"],
|
||||
icon: <ListOrdered size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleOrderedList(editor, range);
|
||||
},
|
||||
{
|
||||
title: "Quote",
|
||||
description: "Capture a quote.",
|
||||
searchTerms: ["blockquote"],
|
||||
icon: <TextQuote size={18} />,
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
toggleBlockquote(editor, range),
|
||||
},
|
||||
{
|
||||
title: "Quote",
|
||||
description: "Capture a quote.",
|
||||
searchTerms: ["blockquote"],
|
||||
icon: <TextQuote size={18} />,
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
toggleBlockquote(editor, range),
|
||||
},
|
||||
{
|
||||
title: "Code",
|
||||
description: "Capture a code snippet.",
|
||||
searchTerms: ["codeblock"],
|
||||
icon: <Code size={18} />,
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
// @ts-expect-error I have to move this to the core
|
||||
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
||||
},
|
||||
{
|
||||
title: "Image",
|
||||
description: "Upload an image from your computer.",
|
||||
searchTerms: ["photo", "picture", "media"],
|
||||
icon: <ImageIcon size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
insertImageCommand(editor, uploadFile, setIsSubmitting, range);
|
||||
},
|
||||
{
|
||||
title: "Code",
|
||||
description: "Capture a code snippet.",
|
||||
searchTerms: ["codeblock"],
|
||||
icon: <Code size={18} />,
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
// @ts-expect-error I have to move this to the core
|
||||
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
||||
},
|
||||
{
|
||||
title: "Image",
|
||||
description: "Upload an image from your computer.",
|
||||
searchTerms: ["photo", "picture", "media"],
|
||||
icon: <ImageIcon size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
insertImageCommand(editor, uploadFile, setIsSubmitting, range);
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
if (additonalOptions) {
|
||||
additonalOptions.map(item => {
|
||||
slashCommands.push(item)
|
||||
})
|
||||
},
|
||||
].filter((item) => {
|
||||
if (typeof query === "string" && query.length > 0) {
|
||||
const search = query.toLowerCase();
|
||||
return (
|
||||
item.title.toLowerCase().includes(search) ||
|
||||
item.description.toLowerCase().includes(search) ||
|
||||
(item.searchTerms &&
|
||||
item.searchTerms.some((term: string) => term.includes(search)))
|
||||
);
|
||||
}
|
||||
|
||||
slashCommands = slashCommands.filter((item) => {
|
||||
if (typeof query === "string" && query.length > 0) {
|
||||
const search = query.toLowerCase();
|
||||
return (
|
||||
item.title.toLowerCase().includes(search) ||
|
||||
item.description.toLowerCase().includes(search) ||
|
||||
(item.searchTerms &&
|
||||
item.searchTerms.some((term: string) => term.includes(search)))
|
||||
);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
|
||||
return slashCommands
|
||||
};
|
||||
return true;
|
||||
});
|
||||
|
||||
export const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
|
||||
const containerHeight = container.offsetHeight;
|
||||
@@ -383,11 +376,10 @@ export const SlashCommand = (
|
||||
setIsSubmitting?: (
|
||||
isSubmitting: "submitting" | "submitted" | "saved",
|
||||
) => void,
|
||||
additonalOptions?: Array<ISlashCommandItem>,
|
||||
) =>
|
||||
Command.configure({
|
||||
suggestion: {
|
||||
items: getSuggestionItems(uploadFile, setIsSubmitting, additonalOptions),
|
||||
items: getSuggestionItems(uploadFile, setIsSubmitting),
|
||||
render: renderItems,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "18.15.3",
|
||||
"@types/react": "^18.2.42",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@types/react": "^18.2.39",
|
||||
"@types/react-dom": "^18.2.14",
|
||||
"eslint": "^7.32.0",
|
||||
"postcss": "^8.4.29",
|
||||
"tailwind-config-custom": "*",
|
||||
|
||||
@@ -38,8 +38,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "18.15.3",
|
||||
"@types/react": "^18.2.42",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@types/react": "^18.2.39",
|
||||
"@types/react-dom": "^18.2.14",
|
||||
"eslint": "^7.32.0",
|
||||
"postcss": "^8.4.29",
|
||||
"react": "^18.2.0",
|
||||
|
||||
@@ -24,10 +24,6 @@ export type IRichTextEditor = {
|
||||
noBorder?: boolean;
|
||||
borderOnFocus?: boolean;
|
||||
cancelUploadImage?: () => any;
|
||||
rerenderOnPropsChange?: {
|
||||
id: string;
|
||||
description_html: string;
|
||||
};
|
||||
customClassName?: string;
|
||||
editorContentCustomClassNames?: string;
|
||||
onChange?: (json: any, html: string) => void;
|
||||
@@ -67,7 +63,6 @@ const RichTextEditor = ({
|
||||
restoreFile,
|
||||
forwardedRef,
|
||||
mentionHighlights,
|
||||
rerenderOnPropsChange,
|
||||
mentionSuggestions,
|
||||
}: RichTextEditorProps) => {
|
||||
const editor = useEditor({
|
||||
@@ -81,7 +76,6 @@ const RichTextEditor = ({
|
||||
deleteFile,
|
||||
restoreFile,
|
||||
forwardedRef,
|
||||
rerenderOnPropsChange,
|
||||
extensions: RichTextEditorExtensions(
|
||||
uploadFile,
|
||||
setIsSubmitting,
|
||||
|
||||
@@ -32,7 +32,6 @@
|
||||
"eslint-config-next": "13.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tiptap/core": "^2.1.12",
|
||||
"@types/node": "18.15.3",
|
||||
"@types/react": "^18.2.39",
|
||||
"@types/react-dom": "^18.2.14",
|
||||
|
||||
@@ -5,4 +5,3 @@ export type {
|
||||
IMentionHighlight,
|
||||
IMentionSuggestion,
|
||||
} from "./types/mention-suggestion";
|
||||
export type { ISlashCommandItem, CommandProps } from "./types/slash-commands-suggestion"
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { ReactNode } from "react";
|
||||
import { Editor, Range } from "@tiptap/core"
|
||||
|
||||
export type CommandProps = {
|
||||
editor: Editor;
|
||||
range: Range;
|
||||
}
|
||||
|
||||
export type ISlashCommandItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
searchTerms: string[];
|
||||
icon: ReactNode;
|
||||
command: ({ editor, range }: CommandProps) => void;
|
||||
}
|
||||
@@ -19,9 +19,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.5.2",
|
||||
"@types/react": "^18.2.42",
|
||||
"@types/react": "^18.2.39",
|
||||
"@types/react-color": "^3.0.9",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"classnames": "^2.3.2",
|
||||
"eslint-config-custom": "*",
|
||||
"react": "^18.2.0",
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
|
||||
// react hook form
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
// services
|
||||
import authenticationService from "services/authentication.service";
|
||||
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import useTimer from "hooks/use-timer";
|
||||
|
||||
// ui
|
||||
import { Input, PrimaryButton } from "components/ui";
|
||||
|
||||
// types
|
||||
type EmailCodeFormValues = {
|
||||
email: string;
|
||||
key?: string;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
export const EmailCodeForm = ({ handleSignIn }: any) => {
|
||||
const [codeSent, setCodeSent] = useState(false);
|
||||
const [codeResent, setCodeResent] = useState(false);
|
||||
const [isCodeResending, setIsCodeResending] = useState(false);
|
||||
const [errorResendingCode, setErrorResendingCode] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
const { timer: resendCodeTimer, setTimer: setResendCodeTimer } = useTimer();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
setValue,
|
||||
getValues,
|
||||
watch,
|
||||
formState: { errors, isSubmitting, isValid, isDirty },
|
||||
} = useForm<EmailCodeFormValues>({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
key: "",
|
||||
token: "",
|
||||
},
|
||||
mode: "onChange",
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
const isResendDisabled = resendCodeTimer > 0 || isCodeResending || isSubmitting || errorResendingCode;
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async ({ email }: EmailCodeFormValues) => {
|
||||
setErrorResendingCode(false);
|
||||
await authenticationService
|
||||
.emailCode({ email })
|
||||
.then((res) => {
|
||||
setValue("key", res.key);
|
||||
setCodeSent(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
setErrorResendingCode(true);
|
||||
setToastAlert({
|
||||
title: "Oops!",
|
||||
type: "error",
|
||||
message: err?.error,
|
||||
});
|
||||
});
|
||||
},
|
||||
[setToastAlert, setValue]
|
||||
);
|
||||
|
||||
const handleSignin = async (formData: EmailCodeFormValues) => {
|
||||
setIsLoading(true);
|
||||
await authenticationService
|
||||
.magicSignIn(formData)
|
||||
.then((response) => {
|
||||
setIsLoading(false);
|
||||
handleSignIn(response);
|
||||
})
|
||||
.catch((error) => {
|
||||
setIsLoading(false);
|
||||
setToastAlert({
|
||||
title: "Oops!",
|
||||
type: "error",
|
||||
message: error?.response?.data?.error ?? "Enter the correct code to sign in",
|
||||
});
|
||||
setError("token" as keyof EmailCodeFormValues, {
|
||||
type: "manual",
|
||||
message: error?.error,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const emailOld = getValues("email");
|
||||
|
||||
useEffect(() => {
|
||||
setErrorResendingCode(false);
|
||||
}, [emailOld]);
|
||||
|
||||
useEffect(() => {
|
||||
const submitForm = (e: KeyboardEvent) => {
|
||||
if (!codeSent && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSubmit(onSubmit)().then(() => {
|
||||
setResendCodeTimer(30);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!codeSent) {
|
||||
window.addEventListener("keydown", submitForm);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", submitForm);
|
||||
};
|
||||
}, [handleSubmit, codeSent, onSubmit, setResendCodeTimer]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{(codeSent || codeResent) && (
|
||||
<p className="text-center mt-4">
|
||||
We have sent the sign in code.
|
||||
<br />
|
||||
Please check your inbox at <span className="font-medium">{watch("email")}</span>
|
||||
</p>
|
||||
)}
|
||||
<form className="space-y-4 mt-10 sm:w-[360px] mx-auto">
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Enter your email address..."
|
||||
className="border-custom-border-300 h-[46px]"
|
||||
{...register("email", {
|
||||
required: "Email address is required",
|
||||
validate: (value) =>
|
||||
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
|
||||
value
|
||||
) || "Email address is not valid",
|
||||
})}
|
||||
/>
|
||||
{errors.email && <div className="text-sm text-red-500">{errors.email.message}</div>}
|
||||
</div>
|
||||
|
||||
{codeSent && (
|
||||
<>
|
||||
<Input
|
||||
id="token"
|
||||
type="token"
|
||||
{...register("token", {
|
||||
required: "Code is required",
|
||||
})}
|
||||
placeholder="Enter code..."
|
||||
className="border-custom-border-300 h-[46px]"
|
||||
/>
|
||||
{errors.token && <div className="text-sm text-red-500">{errors.token.message}</div>}
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full justify-end text-xs outline-none ${
|
||||
isResendDisabled ? "cursor-default text-custom-text-200" : "cursor-pointer text-custom-primary-100"
|
||||
} `}
|
||||
onClick={() => {
|
||||
setIsCodeResending(true);
|
||||
onSubmit({ email: getValues("email") }).then(() => {
|
||||
setCodeResent(true);
|
||||
setIsCodeResending(false);
|
||||
setResendCodeTimer(30);
|
||||
});
|
||||
}}
|
||||
disabled={isResendDisabled}
|
||||
>
|
||||
{resendCodeTimer > 0 ? (
|
||||
<span className="text-right">Request new code in {resendCodeTimer} seconds</span>
|
||||
) : isCodeResending ? (
|
||||
"Sending new code..."
|
||||
) : errorResendingCode ? (
|
||||
"Please try again later"
|
||||
) : (
|
||||
<span className="font-medium">Resend code</span>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{codeSent ? (
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
className="w-full text-center h-[46px]"
|
||||
size="md"
|
||||
onClick={handleSubmit(handleSignin)}
|
||||
disabled={!isValid && isDirty}
|
||||
loading={isLoading}
|
||||
>
|
||||
{isLoading ? "Signing in..." : "Sign in"}
|
||||
</PrimaryButton>
|
||||
) : (
|
||||
<PrimaryButton
|
||||
className="w-full text-center h-[46px]"
|
||||
size="md"
|
||||
onClick={() => {
|
||||
handleSubmit(onSubmit)().then(() => {
|
||||
setResendCodeTimer(30);
|
||||
});
|
||||
}}
|
||||
disabled={!isValid && isDirty}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Sending code..." : "Send sign in code"}
|
||||
</PrimaryButton>
|
||||
)}
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user