Compare commits

...

35 Commits

Author SHA1 Message Date
LAKHAN BAHETI
24f993dbee fix: proper toast message for lower roles 2023-12-08 16:18:23 +05:30
LAKHAN BAHETI
1ff0538077 chore: access control to create issue, cycle, module, pages 2023-12-07 17:47:35 +05:30
Nikhil
0e055666e7 dev: instance refactor (#3015)
* dev: remove license engine communication

* dev: remove license engine base url

* dev: update instance configuration function

* chore: removed the print statement

* chore: changed config variables

* chore: cleanup

* chore: added SKIP_ENV_VAR

* chore: changed the EMAIL_FROM

* dev: patch endpoint for workspace

* dev: custom port for takeoff script

* chore: changed my sequence

* fix: update operaton for member invitations in workspace

* clean-up: remove logs

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: gurusainath <gurusainath007@gmail.com>
2023-12-07 14:51:27 +05:30
sriram veeraghanta
1b98b65a80 fix: adding sentry configs on space app and updated docker ignore (#3018) 2023-12-07 14:43:33 +05:30
rahulramesha
03c8aeed57 Chore: Minify build for plane packages (#3017)
* minify @plane/ui build

* minify all the packages
2023-12-07 14:26:17 +05:30
Henit Chobisa
6c8df73ad4 [ FEATURE ] New Issue Widget for displaying issues inside document-editor (#2920)
* feat: added heading 3 in the editor summary markings

* feat: fixed editor and summary bar sizing

* feat: added `issue-embed` extension

* feat: exposed issue embed extension

* feat: added main embed config configuration to document editor body

* feat: added peek overview and issue embed fetch function

* feat: enabled slash commands to take additonal suggestions from editors

* chore: replaced `IssueEmbedWidget` into widget extension

* chore: removed issue embed from previous places

* feat: added issue embed suggestion extension

* feat: added issue embed suggestion renderer

* feat: added issue embed suggestions into extensions module

* feat: added issues in issueEmbedConfiguration in document editor

* chore: package fixes

* chore: removed log statements

* feat: added title updation logic into document editor

* fix: issue suggestion items, not rendering issue widget on enter

* feat: added error card for issue widget

* feat: improved focus logic for issue search and navigate

* feat: appended transactionid for issueWidgetTransaction

* chore: packages update

* feat: disabled editing of title in readonly mode

* feat: added issueEmbedConfig in readonly editor

* fix: issue suggestions not loading after structure changed to object

* feat: added toast messages for success/error messages from doc editor

* fix: issue suggestions sorting issue

* fix: formatting errors resolved

* fix: infinite reloading of the readonly document editor

* fix: css in avatar of issue widget card

* feat: added show alert on pages reload

* feat: added saving state for the pages editor

* fix: issue with heading 3 in side bar view

* style: updated issue suggestions dropdown ui

* fix: Pages intiliazation and mutation with updated MobX store

* fixed image uploads being cancelled on refocus due to swr

* fix: issue with same description rerendering empty content fixed

* fix: scroll in issue suggestion view

* fix: added submission prop

* fix: Updated the comment update to take issue id in inbox issues

* feat:changed date representation in IssueEmbedCard

* fix: page details mutation with optimistic updates using swr

* fix: menu options in read only editor with auth fixed

* fix: add error handling for title and page desc

* fixed yarn.lock

* fix: read-only editor title wrapping

* fix: build error with rich text editor

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
Co-authored-by: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com>
2023-12-07 12:04:21 +05:30
Lakhan Baheti
074e35525c fix: spreadsheet layout bugs (#3016)
* fix: date picker z visibility

* fix: typo in empty issue screen

* fix: spread sheet column rightmost border
2023-12-07 11:59:46 +05:30
Aaryan Khandelwal
8ea42aa0b6 fix: remove all unused variables and added dependecies to useEffect and useCallback (#3013) 2023-12-06 20:31:42 +05:30
sriram veeraghanta
1df067ff61 fix: upgrading types react package (#3014) 2023-12-06 20:07:39 +05:30
guru_sainath
a79dbdadb5 fix: issue layouts bugs and ui fixes (#3012)
* fix: initial issue creation issue in the list layout

* fix kanban drag n drop and updating properties

* reduce z index of spreadsheet bottom row to not overlap with other elements

* fix state update by using state id instead of state detail's id

* fix add default use state for description

* add create issue button for project views to be at par with production

* save draft issues from modal

* chore: added save view button in all layouts applied filters

* use useEffect instead of swr for fetching issue details for peek overview

* fix: resolved kanban dnd

---------

Co-authored-by: rahulramesha <rahulramesham@gmail.com>
2023-12-06 19:58:47 +05:30
Bavisetti Narayan
bffba6b9dc chore: Page auth and other improvements (#3011)
* chore: project query optimised

* chore: page permissions changed
2023-12-06 19:30:40 +05:30
Aaryan Khandelwal
bd4b5040b2 chore: remove unused fields from the god mode (#3007) 2023-12-06 19:29:45 +05:30
Lakhan Baheti
0d762d74dc fix: kanban board block's menu & drop delete. (#2987)
* fix: kanban board block menu click

* fix: menu active/disable

* fix: drag n drop delete modal

* fix: quick action button in all the layouts

* chore: toast for drag & drop api
2023-12-06 19:21:24 +05:30
Henit Chobisa
567c3fadc0 feat: added custom blockquote extension for resolving enter key behaviour (#2997) 2023-12-06 19:17:47 +05:30
Lakhan Baheti
cb0af255f9 fix: custom analytic grouped bar tooltip value as ID (#3003)
* fix: tooltip value is coming as ID

* fix lint named module
2023-12-06 19:15:07 +05:30
Aaryan Khandelwal
d204cc7d6c chore: added authorization to pages (#3006)
* chore: updated pages authorization

* chore: updated pages empty state image
2023-12-06 19:13:42 +05:30
Anmol Singh Bhatia
5317de5919 chore: plane logo without text updated (#3008) 2023-12-06 19:11:34 +05:30
Anmol Singh Bhatia
b499e2c5a5 fix: bug fixes (#3010)
* fix: project view modal auto close bug fix

* fix: issue peek overview label select permission validation added
2023-12-06 19:10:53 +05:30
Nikhil
956d7acfa6 dev: user password reset management command (#3000) 2023-12-06 18:29:53 +05:30
Nikhil
4040441ab1 dev: remove unused packages (#3009)
* dev: remove unused packages

* dev: remove gunicorn config
2023-12-06 18:28:34 +05:30
Bavisetti Narayan
dc5c0fe5bf chore: posthog event for workspace invite (#2989)
* chore: posthog event for workspace invite

* chore: updated event names, added all the existing events to workspace metrics group

* chore: seperated workspace invite

* fix: workspace invite accept event updated

---------

Co-authored-by: Ramesh Kumar Chandra <rameshkumar2299@gmail.com>
2023-12-06 17:15:14 +05:30
Jorge
675e747fed Add CodeQL workflow (#1452) 2023-12-06 17:07:55 +05:30
Manish Gupta
c9517610da modified docker image repo names (#3004) 2023-12-06 16:47:14 +05:30
Anmol Singh Bhatia
0e1ee80512 chore: updated plane deploy sign-in workflows for cloud and self-hosted instances (#2999)
* chore: deploy onboarding workflow

* chore: sign in workflow improvement

* fix: build error
2023-12-06 16:42:57 +05:30
guru_sainath
0fc273aca6 clean-up: removed labels in the filters and handled redirection issue from peek overview and ui changes (#3002) 2023-12-06 16:27:33 +05:30
Lakhan Baheti
d6b23fe380 fix: bugs & improvements (#2998)
* fix: create more toggle in update issue modal

* fix: spreadsheet estimate column hide

* fix: flickering in all the layouts

* fix: logs
2023-12-06 14:29:07 +05:30
Aaryan Khandelwal
11987994a1 chore: updated sign-in workflows for cloud and self-hosted instances (#2994)
* chore: update onboarding workflow

* dev: update user count tasks

* fix: forgot password endpoint

* dev: instance and onboarding updates

* chore: update sign-in workflow for cloud and self-hosted instances (#2993)

* chore: updated auth services

* chore: new signin workflow updated

* chore: updated content

* chore: instance admin setup

* dev: update instance verification task

* dev: run the instance verification task every 4 hours

* dev: update migrations

* chore: update latest features image

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2023-12-06 14:22:59 +05:30
Anmol Singh Bhatia
b957e4e935 fix: bug fixes and improvement (#2992)
* chore: issue sidebar permission bug fix and not authorized page redirection added

* chore: unauthorized project setting page improvement

* fix: build error fix
2023-12-06 14:22:06 +05:30
M. Palanikannan
e7ac7e1da8 fix: Image Resizing and PR (#2996)
* added image min width and height programatically

* fixed editor initialization for peek view and inbox issues

* fixed ts issues with issue id in inbox
2023-12-06 12:16:34 +05:30
rahulramesha
ac18906604 fix: bugs related to issues (#2995)
* hide properties in list and kanban with 0 or nil values

* module and cycle mutation from peek overlay

* fix peek over view title change while switching

* fix create issue fetching

* fix build errors by mutating the values as well
2023-12-06 01:02:18 +05:30
Anmol Singh Bhatia
aec50e2c48 [FED-1147] chore: module link mobx integration (#2990)
* chore: link type updated

* chore: mobx implementation for module link

* chore: update module mutation logic updated and toast alert added
2023-12-05 19:32:25 +05:30
guru_sainath
8dee7e51ca chore: workspace profile issues, kanabn DND upgrade, implemented filters in plaen deploy (#2991) 2023-12-05 17:26:57 +05:30
Anmol Singh Bhatia
8b2d78ef92 chore: space ui component revamp and bug fixes (#2980)
* chore: replace space ui component with plane ui component

* fix: space project icon and user pic bug

* chore: code refactor

* fix: profile section navbar fix
2023-12-05 16:07:25 +05:30
Lakhan Baheti
9649f42ff3 chore: email invite accept validation (#2965)
* fix: empty state flickering on accepting only invitation

* fix: redirection from workspace-invitaion to onboarding

* chore: onboarding step 1 skip on accepting invite from email

* fix: dashboard redirection path
2023-12-05 16:06:43 +05:30
sabith-tu
db1166411a style: image picker, spreadsheet view title, icons (#2988)
* style: image picker, spreadsheet view title, icons

* fix: build error fix
2023-12-05 16:05:50 +05:30
345 changed files with 9047 additions and 5691 deletions

View File

@@ -2,5 +2,16 @@
*.pyc
.env
venv
node_modules
npm-debug.log
node_modules/
**/node_modules/
npm-debug.log
.next/
**/.next/
.turbo/
**/.turbo/
build/
**/build/
out/
**/out/
dist/
**/dist/

View File

@@ -6,17 +6,18 @@ on:
- closed
branches:
- master
- release
- preview
- qa
- develop
release:
types: [released, prereleased]
env:
TARGET_BRANCH: ${{ github.event.pull_request.base.ref }}
TARGET_BRANCH: ${{ github.event.pull_request.base.ref || github.event.release.target_commitish }}
jobs:
branch_build_setup:
if: ${{ (github.event_name == 'pull_request' && github.event.action =='closed' && github.event.pull_request.merged == true) }}
if: ${{ (github.event_name == 'pull_request' && github.event.action =='closed' && github.event.pull_request.merged == true) || github.event_name == 'release' }}
name: Build-Push Web/Space/API/Proxy Docker Image
runs-on: ubuntu-20.04
@@ -61,14 +62,14 @@ jobs:
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
FRONTEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend-private:${{ needs.branch_build_setup.outputs.gh_branch_name }}
FRONTEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend-ce:${{ needs.branch_build_setup.outputs.gh_branch_name }}
steps:
- name: Set Frontend Docker Tag
run: |
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend-private:latest
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "release" ] || [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "preview" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend-private:preview,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:preview
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
else
TAG=${{ env.FRONTEND_TAG }}
fi
@@ -103,14 +104,14 @@ jobs:
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space-private:${{ needs.branch_build_setup.outputs.gh_branch_name }}
SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space-ce:${{ needs.branch_build_setup.outputs.gh_branch_name }}
steps:
- name: Set Space Docker Tag
run: |
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "release" ] || [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "preview" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space-private:preview,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:preview
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
else
TAG=${{ env.SPACE_TAG }}
fi
@@ -145,14 +146,14 @@ jobs:
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend-private:${{ needs.branch_build_setup.outputs.gh_branch_name }}
BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend-ce:${{ needs.branch_build_setup.outputs.gh_branch_name }}
steps:
- name: Set Backend Docker Tag
run: |
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend-private:latest
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "release" ] || [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "preview" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend-private:preview,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:preview
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
else
TAG=${{ env.BACKEND_TAG }}
fi
@@ -187,14 +188,14 @@ jobs:
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy-private:${{ needs.branch_build_setup.outputs.gh_branch_name }}
PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy-ce:${{ needs.branch_build_setup.outputs.gh_branch_name }}
steps:
- name: Set Proxy Docker Tag
run: |
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy-private:latest
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "release" ] || [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "preview" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy-private:preview,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:preview
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
else
TAG=${{ env.PROXY_TAG }}
fi

65
.github/workflows/codeql.yml vendored Normal file
View File

@@ -0,0 +1,65 @@
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}}"

View File

@@ -1,107 +0,0 @@
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 }}

View File

@@ -79,7 +79,6 @@ 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/

View File

@@ -44,7 +44,6 @@ 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/

View File

@@ -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: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:${PORT:-8000} --max-requests 1200 --max-requests-jitter 1000 --access-logfile -

0
apiserver/file.txt Normal file
View File

View File

@@ -1,6 +0,0 @@
from psycogreen.gevent import patch_psycopg
def post_fork(server, worker):
patch_psycopg()
worker.log.info("Made Psycopg2 Green")

View File

@@ -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 settings"
"error": "Inbox is not enabled for this project enable it through the project's api"
},
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 settings"
"error": "Inbox is not enabled for this project enable it through the project's api"
},
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 settings"
"error": "Inbox is not enabled for this project enable it through the project's api"
},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@@ -103,16 +103,19 @@ class ProjectListSerializer(DynamicBaseSerializer):
members = serializers.SerializerMethodField()
def get_members(self, obj):
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)
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 []
class Meta:
model = Project

View File

@@ -95,6 +95,16 @@ class WorkSpaceMemberInviteSerializer(BaseSerializer):
class Meta:
model = WorkspaceMemberInvite
fields = "__all__"
read_only_fields = [
"id",
"email",
"token",
"workspace",
"message",
"responded_at",
"created_at",
"updated_at",
]
class TeamSerializer(BaseSerializer):

View File

@@ -7,6 +7,7 @@ from plane.app.views import (
# Authentication
SignInEndpoint,
SignOutEndpoint,
MagicGenerateEndpoint,
MagicSignInEndpoint,
OauthEndpoint,
EmailCheckEndpoint,
@@ -30,6 +31,7 @@ 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

View File

@@ -65,6 +65,7 @@ urlpatterns = [
{
"delete": "destroy",
"get": "retrieve",
"patch": "partial_update",
}
),
name="workspace-invitations",

View File

@@ -87,6 +87,7 @@ from .auth_extended import (
ChangePasswordEndpoint,
SetUserPasswordEndpoint,
EmailCheckEndpoint,
MagicGenerateEndpoint,
)
@@ -129,7 +130,6 @@ from .page import (
PageFavoriteViewSet,
PageLogEndpoint,
SubPagesEndpoint,
CreateIssueFromBlockEndpoint,
)
from .search import GlobalSearchEndpoint, IssueSearchEndpoint

View File

@@ -34,12 +34,12 @@ from plane.app.serializers import (
from plane.db.models import User, WorkspaceMemberInvite
from plane.license.utils.instance_value import get_configuration_value
from plane.bgtasks.forgot_password_task import forgot_password
from plane.license.models import Instance, InstanceConfiguration
from plane.license.models import Instance
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 (
@@ -108,13 +108,16 @@ class ForgotPasswordEndpoint(BaseAPIView):
try:
validate_email(email)
except ValidationError:
return Response({"error": "Please enter a valid email"}, status=status.HTTP_400_BAD_REQUEST)
return Response(
{"error": "Please enter a valid email"},
status=status.HTTP_400_BAD_REQUEST,
)
# Get the user
user = User.objects.filter(email=email).first()
if user:
# Get the reset token for user
uidb64, token = get_tokens_for_user(user=user)
uidb64, token = generate_password_token(user=user)
current_site = request.META.get("HTTP_ORIGIN")
# send the forgot password email
forgot_password.delay(
@@ -130,7 +133,9 @@ class ForgotPasswordEndpoint(BaseAPIView):
class ResetPasswordEndpoint(BaseAPIView):
permission_classes = [AllowAny,]
permission_classes = [
AllowAny,
]
def post(self, request, uidb64, token):
try:
@@ -219,6 +224,89 @@ 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,
@@ -233,20 +321,34 @@ class EmailCheckEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
# Get the configurations
instance_configuration = InstanceConfiguration.objects.values("key", "value")
# Get configuration values
ENABLE_SIGNUP, ENABLE_MAGIC_LINK_LOGIN = get_configuration_value(
[
{
"key": "ENABLE_SIGNUP",
"default": os.environ.get("ENABLE_SIGNUP"),
},
{
"key": "ENABLE_MAGIC_LINK_LOGIN",
"default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN"),
},
]
)
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()
@@ -256,12 +358,7 @@ class EmailCheckEndpoint(BaseAPIView):
if user is None:
# Create the user
if (
get_configuration_value(
instance_configuration,
"ENABLE_SIGNUP",
os.environ.get("ENABLE_SIGNUP", "0"),
)
== "0"
ENABLE_SIGNUP == "0"
and not WorkspaceMemberInvite.objects.filter(
email=email,
).exists()
@@ -281,109 +378,90 @@ class EmailCheckEndpoint(BaseAPIView):
is_password_autoset=True,
)
# Update instance user count
update_user_instance_user_count.delay()
# 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}, 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
if not bool(
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="EMAIL",
first_time=True,
)
# Automatically send the email
return Response({"is_password_autoset": user.is_password_autoset}, status=status.HTTP_200_OK)
# Send event
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 type == "magic_code":
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")),
):
if not bool(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,
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=False,
)
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=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}, status=status.HTTP_200_OK)
return Response(
{
"is_password_autoset": user.is_password_autoset,
"is_existing": True,
},
status=status.HTTP_200_OK,
)
else:
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=False,
)
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)
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=False,
)
# User should enter password to login
return Response(
{
"is_password_autoset": user.is_password_autoset,
"is_existing": True,
},
status=status.HTTP_200_OK,
)

View File

@@ -1,8 +1,6 @@
# Python imports
import os
import uuid
import random
import string
import json
# Django imports
@@ -10,6 +8,7 @@ from django.utils import timezone
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.conf import settings
from django.contrib.auth.hashers import make_password
# Third party imports
from rest_framework.response import Response
@@ -28,10 +27,9 @@ from plane.db.models import (
ProjectMember,
)
from plane.settings.redis import redis_instance
from plane.license.models import InstanceConfiguration, Instance
from plane.license.models import Instance
from plane.license.utils.instance_value import get_configuration_value
from plane.bgtasks.event_tracking_task import auth_events
from plane.bgtasks.user_count_task import update_user_instance_user_count
def get_tokens_for_user(user):
@@ -54,11 +52,8 @@ class SignUpEndpoint(BaseAPIView):
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(
@@ -66,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:
@@ -76,14 +71,20 @@ class SignUpEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
# get configuration values
# Get configuration values
ENABLE_SIGNUP, = get_configuration_value(
[
{
"key": "ENABLE_SIGNUP",
"default": os.environ.get("ENABLE_SIGNUP"),
},
]
)
# 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,
"ENABLE_SIGNUP",
os.environ.get("ENABLE_SIGNUP", "0"),
)
== "0"
ENABLE_SIGNUP == "0"
and not WorkspaceMemberInvite.objects.filter(
email=email,
).exists()
@@ -106,6 +107,7 @@ 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")
@@ -120,9 +122,6 @@ 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)
@@ -148,8 +147,8 @@ class SignInEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
# Validate email
email = email.strip().lower()
try:
validate_email(email)
except ValidationError as e:
@@ -161,22 +160,46 @@ class SignInEndpoint(BaseAPIView):
# Get the user
user = User.objects.filter(email=email).first()
# 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,
)
# 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,
)
# 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,
# Create the user
else:
ENABLE_SIGNUP, = get_configuration_value(
[
{
"key": "ENABLE_SIGNUP",
"default": os.environ.get("ENABLE_SIGNUP"),
},
]
)
# Create the user
if (
ENABLE_SIGNUP == "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,
)
# settings last active for the user
@@ -246,16 +269,15 @@ class SignInEndpoint(BaseAPIView):
workspace_member_invites.delete()
project_member_invites.delete()
# 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=False,
)
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=False,
)
access_token, refresh_token = get_tokens_for_user(user)
data = {
@@ -329,16 +351,15 @@ class MagicSignInEndpoint(BaseAPIView):
status=status.HTTP_403_FORBIDDEN,
)
# 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=False,
)
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=False,
)
user.is_active = True
user.is_email_verified = True

View File

@@ -11,7 +11,6 @@ from rest_framework.response import Response
# Module imports
from .base import BaseAPIView
from plane.license.models import Instance, InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value
@@ -21,87 +20,101 @@ class ConfigurationEndpoint(BaseAPIView):
]
def get(self, request):
instance_configuration = InstanceConfiguration.objects.values("key", "value")
# Get all the configuration
(
GOOGLE_CLIENT_ID,
GITHUB_CLIENT_ID,
GITHUB_APP_NAME,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
ENABLE_MAGIC_LINK_LOGIN,
ENABLE_EMAIL_PASSWORD,
SLACK_CLIENT_ID,
POSTHOG_API_KEY,
POSTHOG_HOST,
UNSPLASH_ACCESS_KEY,
OPENAI_API_KEY,
) = get_configuration_value(
[
{
"key": "GOOGLE_CLIENT_ID",
"default": os.environ.get("GOOGLE_CLIENT_ID", None),
},
{
"key": "GITHUB_CLIENT_ID",
"default": os.environ.get("GITHUB_CLIENT_ID", None),
},
{
"key": "GITHUB_APP_NAME",
"default": os.environ.get("GITHUB_APP_NAME", None),
},
{
"key": "EMAIL_HOST_USER",
"default": os.environ.get("EMAIL_HOST_USER", None),
},
{
"key": "EMAIL_HOST_PASSWORD",
"default": os.environ.get("EMAIL_HOST_PASSWORD", None),
},
{
"key": "ENABLE_MAGIC_LINK_LOGIN",
"default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "1"),
},
{
"key": "ENABLE_EMAIL_PASSWORD",
"default": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"),
},
{
"key": "SLACK_CLIENT_ID",
"default": os.environ.get("SLACK_CLIENT_ID", "1"),
},
{
"key": "POSTHOG_API_KEY",
"default": os.environ.get("POSTHOG_API_KEY", "1"),
},
{
"key": "POSTHOG_HOST",
"default": os.environ.get("POSTHOG_HOST", "1"),
},
{
"key": "UNSPLASH_ACCESS_KEY",
"default": os.environ.get("UNSPLASH_ACCESS_KEY", "1"),
},
{
"key": "OPENAI_API_KEY",
"default": os.environ.get("OPENAI_API_KEY", "1"),
},
]
)
data = {}
# Authentication
data["google_client_id"] = get_configuration_value(
instance_configuration,
"GOOGLE_CLIENT_ID",
os.environ.get("GOOGLE_CLIENT_ID", None),
)
data["github_client_id"] = get_configuration_value(
instance_configuration,
"GITHUB_CLIENT_ID",
os.environ.get("GITHUB_CLIENT_ID", None),
)
data["github_app_name"] = get_configuration_value(
instance_configuration,
"GITHUB_APP_NAME",
os.environ.get("GITHUB_APP_NAME", None),
)
data["google_client_id"] = GOOGLE_CLIENT_ID
data["github_client_id"] = GITHUB_CLIENT_ID
data["github_app_name"] = GITHUB_APP_NAME
data["magic_login"] = (
bool(
get_configuration_value(
instance_configuration,
"EMAIL_HOST_USER",
os.environ.get("EMAIL_HOST_USER", None),
),
)
and bool(
get_configuration_value(
instance_configuration,
"EMAIL_HOST_PASSWORD",
os.environ.get("EMAIL_HOST_PASSWORD", None),
)
)
) and get_configuration_value(
instance_configuration, "ENABLE_MAGIC_LINK_LOGIN", "1"
) == "1"
bool(EMAIL_HOST_USER) and bool(EMAIL_HOST_PASSWORD)
) and ENABLE_MAGIC_LINK_LOGIN == "1"
data["email_password_login"] = (
get_configuration_value(
instance_configuration, "ENABLE_EMAIL_PASSWORD", "1"
)
== "1"
)
data["email_password_login"] = ENABLE_EMAIL_PASSWORD == "1"
# Slack client
data["slack_client_id"] = get_configuration_value(
instance_configuration,
"SLACK_CLIENT_ID",
os.environ.get("SLACK_CLIENT_ID", None),
)
data["slack_client_id"] = SLACK_CLIENT_ID
# Posthog
data["posthog_api_key"] = get_configuration_value(
instance_configuration,
"POSTHOG_API_KEY",
os.environ.get("POSTHOG_API_KEY", None),
)
data["posthog_host"] = get_configuration_value(
instance_configuration,
"POSTHOG_HOST",
os.environ.get("POSTHOG_HOST", None),
)
data["posthog_api_key"] = POSTHOG_API_KEY
data["posthog_host"] = POSTHOG_HOST
# Unsplash
data["has_unsplash_configured"] = bool(
get_configuration_value(
instance_configuration,
"UNSPLASH_ACCESS_KEY",
os.environ.get("UNSPLASH_ACCESS_KEY", None),
)
)
data["has_unsplash_configured"] = UNSPLASH_ACCESS_KEY
# Open AI settings
data["has_openai_configured"] = bool(
get_configuration_value(
instance_configuration,
"OPENAI_API_KEY",
os.environ.get("OPENAI_API_KEY", None),
)
)
data["has_openai_configured"] = bool(OPENAI_API_KEY)
# File size settings
data["file_size_limit"] = float(os.environ.get("FILE_SIZE_LIMIT", 5242880))
# is self managed
data["is_self_managed"] = bool(int(os.environ.get("IS_SELF_MANAGED", "1")))
return Response(data, status=status.HTTP_200_OK)

View File

@@ -1,6 +1,7 @@
# Python imports
import requests
import os
# Third party imports
from openai import OpenAI
from rest_framework.response import Response
@@ -15,23 +16,31 @@ from plane.app.permissions import ProjectEntityPermission
from plane.db.models import Workspace, Project
from plane.app.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer
from plane.utils.integrations.github import get_release_notes
from plane.license.models import InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value
class GPTIntegrationEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def post(self, request, slug, project_id):
OPENAI_API_KEY, GPT_ENGINE = get_configuration_value(
[
{
"key": "OPENAI_API_KEY",
"default": os.environ.get("OPENAI_API_KEY", None),
},
{
"key": "GPT_ENGINE",
"default": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"),
},
]
)
# Get the configuration value
instance_configuration = InstanceConfiguration.objects.values("key", "value")
api_key = get_configuration_value(instance_configuration, "OPENAI_API_KEY", os.environ.get("OPENAI_API_KEY"))
gpt_engine = get_configuration_value(instance_configuration, "GPT_ENGINE", os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"))
# Check the keys
if not api_key or not gpt_engine:
if not OPENAI_API_KEY or not GPT_ENGINE:
return Response(
{"error": "OpenAI API key and engine is required"},
status=status.HTTP_400_BAD_REQUEST,
@@ -48,11 +57,11 @@ class GPTIntegrationEndpoint(BaseAPIView):
final_text = task + "\n" + prompt
client = OpenAI(
api_key=api_key,
api_key=OPENAI_API_KEY,
)
response = client.chat.completions.create(
model=gpt_engine,
model=GPT_ENGINE,
messages=[{"role": "user", "content": final_text}],
)
@@ -79,13 +88,17 @@ class ReleaseNotesEndpoint(BaseAPIView):
class UnsplashEndpoint(BaseAPIView):
def get(self, request):
instance_configuration = InstanceConfiguration.objects.values("key", "value")
unsplash_access_key = get_configuration_value(instance_configuration, "UNSPLASH_ACCESS_KEY", os.environ.get("UNSPLASH_ACCESS_KEY"))
UNSPLASH_ACCESS_KEY, = get_configuration_value(
[
{
"key": "UNSPLASH_ACCESS_KEY",
"default": os.environ.get("UNSPLASH_ACCESS_KEY"),
}
]
)
# Check unsplash access key
if not unsplash_access_key:
if not UNSPLASH_ACCESS_KEY:
return Response([], status=status.HTTP_200_OK)
# Query parameters
@@ -94,9 +107,9 @@ class UnsplashEndpoint(BaseAPIView):
per_page = request.GET.get("per_page", 20)
url = (
f"https://api.unsplash.com/search/photos/?client_id={unsplash_access_key}&query={query}&page=${page}&per_page={per_page}"
f"https://api.unsplash.com/search/photos/?client_id={UNSPLASH_ACCESS_KEY}&query={query}&page=${page}&per_page={per_page}"
if query
else f"https://api.unsplash.com/photos/?client_id={unsplash_access_key}&page={page}&per_page={per_page}"
else f"https://api.unsplash.com/photos/?client_id={UNSPLASH_ACCESS_KEY}&page={page}&per_page={per_page}"
)
headers = {

View File

@@ -30,9 +30,8 @@ from plane.db.models import (
)
from plane.bgtasks.event_tracking_task import auth_events
from .base import BaseAPIView
from plane.license.models import InstanceConfiguration, Instance
from plane.license.models import 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):
@@ -148,18 +147,20 @@ class OauthEndpoint(BaseAPIView):
id_token = request.data.get("credential", False)
client_id = request.data.get("clientId", False)
instance_configuration = InstanceConfiguration.objects.values(
"key", "value"
GOOGLE_CLIENT_ID, GITHUB_CLIENT_ID = get_configuration_value(
[
{
"key": "GOOGLE_CLIENT_ID",
"default": os.environ.get("GOOGLE_CLIENT_ID"),
},
{
"key": "GITHUB_CLIENT_ID",
"default": os.environ.get("GITHUB_CLIENT_ID"),
},
]
)
if not get_configuration_value(
instance_configuration,
"GOOGLE_CLIENT_ID",
os.environ.get("GOOGLE_CLIENT_ID"),
) or not get_configuration_value(
instance_configuration,
"GITHUB_CLIENT_ID",
os.environ.get("GITHUB_CLIENT_ID"),
):
if not GOOGLE_CLIENT_ID or not GITHUB_CLIENT_ID:
return Response(
{"error": "Github or Google login is not configured"},
status=status.HTTP_400_BAD_REQUEST,
@@ -279,16 +280,15 @@ class OauthEndpoint(BaseAPIView):
)
# 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=medium.upper(),
first_time=False,
)
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=medium.upper(),
first_time=False,
)
access_token, refresh_token = get_tokens_for_user(user)
@@ -299,17 +299,16 @@ class OauthEndpoint(BaseAPIView):
return Response(data, status=status.HTTP_200_OK)
except User.DoesNotExist:
## Signup Case
instance_configuration = InstanceConfiguration.objects.values(
"key", "value"
ENABLE_SIGNUP, = get_configuration_value(
[
{
"key": "ENABLE_SIGNUP",
"default": os.environ.get("ENABLE_SIGNUP", "0"),
}
]
)
if (
get_configuration_value(
instance_configuration,
"ENABLE_SIGNUP",
os.environ.get("ENABLE_SIGNUP", "0"),
)
== "0"
ENABLE_SIGNUP == "0"
and not WorkspaceMemberInvite.objects.filter(
email=email,
).exists()
@@ -412,16 +411,15 @@ class OauthEndpoint(BaseAPIView):
project_member_invites.delete()
# 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=medium.upper(),
first_time=True,
)
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=medium.upper(),
first_time=True,
)
SocialLoginConnection.objects.update_or_create(
medium=medium,
@@ -439,6 +437,4 @@ 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)

View File

@@ -22,6 +22,7 @@ from plane.db.models import (
IssueAssignee,
IssueActivity,
PageLog,
ProjectMember,
)
from plane.app.serializers import (
PageSerializer,
@@ -140,12 +141,6 @@ 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)
@@ -155,12 +150,6 @@ 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()
@@ -175,10 +164,16 @@ 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)
if page.owned_by_id != request.user.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
):
return Response(
{"error": "Only the owner of the page can archive a page"},
status=status.HTTP_204_NO_CONTENT,
{"error": "Only the owner and admin can archive the page"},
status=status.HTTP_400_BAD_REQUEST,
)
unarchive_archive_page_and_descendants(page_id, datetime.now())
@@ -188,9 +183,15 @@ 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)
if page.owned_by_id != request.user.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
):
return Response(
{"error": "Only the owner of the page can unarchive a page"},
{"error": "Only the owner and admin can un archive the page"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -216,6 +217,18 @@ 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"},
@@ -227,7 +240,6 @@ 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)
@@ -310,36 +322,6 @@ 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,

View File

@@ -165,6 +165,7 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
workspace__slug=slug,
is_active=True,
).select_related("member"),
to_attr='members_list'
)
)
.order_by("sort_order", "name")

View File

@@ -73,8 +73,7 @@ from plane.app.permissions import (
)
from plane.bgtasks.workspace_invitation_task import workspace_invitation
from plane.utils.issue_filters import issue_filters
from plane.utils.grouper import group_results
from plane.bgtasks.event_tracking_task import workspace_invite_event
class WorkSpaceViewSet(BaseViewSet):
model = Workspace
@@ -407,6 +406,16 @@ class WorkspaceJoinEndpoint(BaseAPIView):
# Delete the invitation
workspace_invite.delete()
# Send event
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"},

View File

@@ -18,7 +18,6 @@ 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
row_mapping = {
@@ -52,11 +51,6 @@ def send_export_email(email, slug, csv_buffer, rows):
csv_buffer.seek(0)
# Configure email connection from the database
instance_configuration = InstanceConfiguration.objects.filter(
key__startswith="EMAIL_"
).values("key", "value")
(
EMAIL_HOST,
EMAIL_HOST_USER,
@@ -64,31 +58,7 @@ def send_export_email(email, slug, csv_buffer, rows):
EMAIL_PORT,
EMAIL_USE_TLS,
EMAIL_FROM,
) = get_email_configuration(instance_configuration=instance_configuration)
# Send the email if the users don't have smtp configured
if EMAIL_HOST and EMAIL_HOST_USER and EMAIL_HOST_PASSWORD:
# Check the instance registration
instance = Instance.objects.first()
headers = {
"Content-Type": "application/json",
"x-instance-id": instance.instance_id,
"x-api-key": instance.api_key,
}
payload = {
"email": email,
"slug": slug,
"rows": rows,
}
_ = requests.post(
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/analytics/",
headers=headers,
json=payload,
)
return
) = get_email_configuration()
connection = get_connection(
host=EMAIL_HOST,

View File

@@ -1,30 +1,78 @@
import uuid
import os
from posthog import Posthog
from django.conf import settings
#third party imports
# third party imports
from celery import shared_task
from sentry_sdk import capture_exception
from posthog import Posthog
# module imports
from plane.license.utils.instance_value import get_configuration_value
def posthogConfiguration():
POSTHOG_API_KEY, POSTHOG_HOST = get_configuration_value(
[
{
"key": "POSTHOG_API_KEY",
"default": os.environ.get("POSTHOG_API_KEY", None),
},
{
"key": "POSTHOG_HOST",
"default": os.environ.get("POSTHOG_HOST", None),
},
]
)
if POSTHOG_API_KEY and POSTHOG_HOST:
return POSTHOG_API_KEY, POSTHOG_HOST
else:
return None, None
@shared_task
def auth_events(user, email, user_agent, ip, event_name, medium, first_time):
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,
},
"medium": medium,
"first_time": first_time
}
)
POSTHOG_API_KEY, POSTHOG_HOST = posthogConfiguration()
if POSTHOG_API_KEY and POSTHOG_HOST:
posthog = Posthog(POSTHOG_API_KEY, host=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,
},
"medium": medium,
"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_API_KEY, POSTHOG_HOST = posthogConfiguration()
if POSTHOG_API_KEY and POSTHOG_HOST:
posthog = Posthog(POSTHOG_API_KEY, host=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)

View File

@@ -14,7 +14,6 @@ from celery import shared_task
from sentry_sdk import capture_exception
# Module imports
from plane.license.models import InstanceConfiguration, Instance
from plane.license.utils.instance_value import get_email_configuration
@@ -26,10 +25,6 @@ def forgot_password(first_name, email, uidb64, token, current_site):
)
abs_url = str(current_site) + relative_link
instance_configuration = InstanceConfiguration.objects.filter(
key__startswith="EMAIL_"
).values("key", "value")
(
EMAIL_HOST,
EMAIL_HOST_USER,
@@ -37,33 +32,7 @@ def forgot_password(first_name, email, uidb64, token, current_site):
EMAIL_PORT,
EMAIL_USE_TLS,
EMAIL_FROM,
) = get_email_configuration(instance_configuration=instance_configuration)
# Send the email if the users don't have smtp configured
if not (EMAIL_HOST and EMAIL_HOST_USER and EMAIL_HOST_PASSWORD):
# Check the instance registration
instance = Instance.objects.first()
# 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"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/forgot-password/",
headers=headers,
data=json.dumps(payload),
)
return
) = get_email_configuration()
subject = "A new password to your Plane account has been requested"
@@ -77,9 +46,6 @@ def forgot_password(first_name, email, uidb64, token, current_site):
text_content = strip_tags(html_content)
instance_configuration = InstanceConfiguration.objects.filter(
key__startswith="EMAIL_"
).values("key", "value")
connection = get_connection(
host=EMAIL_HOST,
port=int(EMAIL_PORT),

View File

@@ -26,7 +26,6 @@ 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
@@ -121,9 +120,6 @@ 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):

View File

@@ -14,17 +14,12 @@ from celery import shared_task
from sentry_sdk import capture_exception
# Module imports
from plane.license.models import InstanceConfiguration, Instance
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")
(
EMAIL_HOST,
EMAIL_HOST_USER,
@@ -32,31 +27,7 @@ def magic_link(email, key, token, current_site):
EMAIL_PORT,
EMAIL_USE_TLS,
EMAIL_FROM,
) = get_email_configuration(instance_configuration=instance_configuration)
# Send the email if the users don't have smtp configured
if not (EMAIL_HOST and EMAIL_HOST_USER and EMAIL_HOST_PASSWORD):
# Check the instance registration
instance = Instance.objects.first()
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"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/magic-code/",
headers=headers,
data=json.dumps(payload),
)
return
) = get_email_configuration()
# Send the mail
subject = f"Your unique Plane login code is {token}"

View File

@@ -13,8 +13,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_configuration_value
from plane.license.utils.instance_value import get_email_configuration
@shared_task
def project_invitation(email, project_id, token, current_site, invitor):
@@ -47,43 +46,27 @@ def project_invitation(email, project_id, token, current_site, invitor):
project_member_invite.save()
# 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()
connection = get_connection(
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"),
)
),
host=EMAIL_HOST,
port=int(EMAIL_PORT),
username=EMAIL_HOST_USER,
password=EMAIL_HOST_PASSWORD,
use_tls=bool(EMAIL_USE_TLS),
)
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=get_configuration_value(
instance_configuration,
"EMAIL_FROM",
os.environ.get("EMAIL_FROM", "Team Plane <team@mailer.plane.so>"),
),
from_email=EMAIL_FROM,
to=[email],
connection=connection,
)

View File

@@ -1,45 +0,0 @@
# 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,
}
# Update the license engine
_ = requests.post(
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers,
data=json.dumps(payload),
)
except Exception as e:
if settings.DEBUG:
print(e)
capture_exception(e)

View File

@@ -17,7 +17,6 @@ from slack_sdk.errors import SlackApiError
# Module imports
from plane.db.models import Workspace, WorkspaceMemberInvite, User
from plane.license.models import InstanceConfiguration, Instance
from plane.license.utils.instance_value import get_email_configuration
@@ -37,9 +36,6 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
# The complete url including the domain
abs_url = str(current_site) + relative_link
instance_configuration = InstanceConfiguration.objects.filter(
key__startswith="EMAIL_"
).values("key", "value")
(
EMAIL_HOST,
@@ -48,32 +44,7 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
EMAIL_PORT,
EMAIL_USE_TLS,
EMAIL_FROM,
) = get_email_configuration(instance_configuration=instance_configuration)
# Send the email if the users don't have smtp configured
if not (EMAIL_HOST and EMAIL_HOST_USER and EMAIL_HOST_PASSWORD):
# Check the instance registration
instance = Instance.objects.first()
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"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/workspace-invitation/",
headers=headers,
data=json.dumps(payload),
)
return
) = get_email_configuration()
# 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"
@@ -94,9 +65,6 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
workspace_member_invite.message = text_content
workspace_member_invite.save()
instance_configuration = InstanceConfiguration.objects.filter(
key__startswith="EMAIL_"
).values("key", "value")
connection = get_connection(
host=EMAIL_HOST,
port=int(EMAIL_PORT),

View File

@@ -33,4 +33,4 @@ app.conf.beat_schedule = {
# 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"

View File

@@ -0,0 +1,54 @@
# 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"))

View File

@@ -43,7 +43,7 @@ class InstanceConfigurationSerializer(BaseSerializer):
def to_representation(self, instance):
data = super().to_representation(instance)
# Decrypt secrets value
if instance.key in ["OPENAI_API_KEY", "GITHUB_CLIENT_SECRET", "EMAIL_HOST_PASSWORD", "UNSPLASH_ACESS_KEY"] and instance.value is not None:
if instance.is_encrypted and instance.value is not None:
data["value"] = decrypt_data(instance.value)
return data
return data

View File

@@ -2,8 +2,6 @@ from .instance import (
InstanceEndpoint,
InstanceAdminEndpoint,
InstanceConfigurationEndpoint,
AdminSetupMagicSignInEndpoint,
InstanceAdminSignInEndpoint,
SignUpScreenVisitedEndpoint,
AdminMagicSignInGenerateEndpoint,
AdminSetUserPasswordEndpoint,
)

View File

@@ -27,14 +27,11 @@ 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
class InstanceEndpoint(BaseAPIView):
@@ -47,61 +44,6 @@ 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)
headers = {"Content-Type": "application/json"}
payload = {
"instance_key":settings.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"{settings.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
@@ -122,24 +64,6 @@ class InstanceEndpoint(BaseAPIView):
serializer = InstanceSerializer(instance, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
# Save the user in control center
headers = {
"Content-Type": "application/json",
"x-instance-id": instance.instance_id,
"x-api-key": instance.api_key,
}
# Update instance settings in the license engine
_ = requests.patch(
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers,
data=json.dumps(
{
"is_support_required": serializer.data["is_support_required"],
"is_telemetry_enabled": serializer.data["is_telemetry_enabled"],
"version": serializer.data["version"],
}
),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -212,12 +136,7 @@ class InstanceConfigurationEndpoint(BaseAPIView):
bulk_configurations = []
for configuration in configurations:
value = request.data.get(configuration.key, configuration.value)
if value is not None and configuration.key in [
"OPENAI_API_KEY",
"GITHUB_CLIENT_SECRET",
"EMAIL_HOST_PASSWORD",
"UNSPLASH_ACESS_KEY",
]:
if configuration.is_encrypted:
configuration.value = encrypt_data(value)
else:
configuration.value = value
@@ -239,15 +158,13 @@ def get_tokens_for_user(user):
)
class AdminMagicSignInGenerateEndpoint(BaseAPIView):
class InstanceAdminSignInEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def post(self, request):
email = request.data.get("email", False)
# Check the instance registration
# Check instance first
instance = Instance.objects.first()
if instance is None:
return Response(
@@ -255,193 +172,63 @@ class AdminMagicSignInGenerateEndpoint(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,
)
if not email:
return Response(
{"error": "Please provide a valid email address"},
status=status.HTTP_400_BAD_REQUEST,
)
# Clean up
email = email.strip().lower()
validate_email(email)
# 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(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 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)
# Get the email and password from all the user
email = request.data.get("email", False)
password = request.data.get("password", False)
# If the user password is not autoset then return error
if not user.is_password_autoset:
# return error if the email and password is not present
if not email or not password:
return Response(
{
"error": "Your password is already set please change your password from profile"
},
{"error": "Email and password are required"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check password validation
if not password and len(str(password)) < 8:
# Validate the email
email = email.strip().lower()
try:
validate_email(email)
except ValidationError as e:
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"},
{"error": "Please provide a valid email address."},
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"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers,
data=json.dumps({"is_setup_done": True}),
)
# Check if already a user exists or not
user = User.objects.filter(email=email).first()
# Also register the user as admin
_ = requests.post(
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/register/",
headers=headers,
data=json.dumps(
{
"email": str(user.email),
"signup_mode": "MAGIC_CODE",
"is_admin": True,
}
),
)
# 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(
email=email,
username=uuid.uuid4().hex,
password=make_password(password),
is_password_autoset=False,
)
# 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()
# Register the user as an instance admin
_ = InstanceAdmin.objects.create(
@@ -452,12 +239,13 @@ class AdminSetUserPasswordEndpoint(BaseAPIView):
instance.is_setup_done = True
instance.save()
# 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)
# 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)
class SignUpScreenVisitedEndpoint(BaseAPIView):
@@ -467,27 +255,11 @@ 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,
)
if not instance.is_signup_screen_visited:
instance.is_signup_screen_visited = True
instance.save()
# set the headers
headers = {
"Content-Type": "application/json",
"x-instance-id": instance.instance_id,
"x-api-key": instance.api_key,
}
# create the payload
payload = {"is_signup_screen_visited": True}
_ = requests.patch(
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers,
data=json.dumps(payload),
)
instance.is_signup_screen_visited = True
instance.save()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -21,84 +21,91 @@ 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": encrypt_data(os.environ.get("GITHUB_CLIENT_SECRET"))
if os.environ.get("GITHUB_CLIENT_SECRET")
else None,
"value": os.environ.get("GITHUB_CLIENT_SECRET"),
"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": encrypt_data(os.environ.get("EMAIL_HOST_PASSWORD"))
if os.environ.get("EMAIL_HOST_PASSWORD")
else None,
"value": os.environ.get("EMAIL_HOST_PASSWORD", ""),
"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": encrypt_data(os.environ.get("OPENAI_API_KEY"))
if os.environ.get("OPENAI_API_KEY")
else None,
"value": os.environ.get("OPENAI_API_KEY"),
"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": encrypt_data(os.environ.get("UNSPLASH_ACESS_KEY", ""))
if os.environ.get("UNSPLASH_ACESS_KEY")
else None,
"value": os.environ.get("UNSPLASH_ACESS_KEY", ""),
"category": "UNSPLASH",
"is_encrypted": True,
},
]
@@ -107,8 +114,12 @@ 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(

View File

@@ -1,7 +1,7 @@
# Python imports
import json
import os
import requests
import secrets
# Django imports
from django.core.management.base import BaseCommand, CommandError
@@ -30,14 +30,11 @@ class Command(BaseCommand):
# Load JSON content from the file
data = json.load(file)
machine_signature = options.get("machine_signature", False)
machine_signature = options.get("machine_signature", "machine-signature")
if not machine_signature:
raise CommandError("Machine signature is required")
headers = {"Content-Type": "application/json"}
payload = {
"instance_key": settings.INSTANCE_KEY,
"version": data.get("version", 0.1),
@@ -45,32 +42,21 @@ class Command(BaseCommand):
"user_count": User.objects.filter(is_bot=False).count(),
}
response = requests.post(
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers,
data=json.dumps(payload),
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"),
last_checked_at=timezone.now(),
user_count=payload.get("user_count", 0),
)
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),
self.stdout.write(
self.style.SUCCESS(
f"Instance registered"
)
self.stdout.write(
self.style.SUCCESS(
f"Instance successfully registered"
)
)
return
raise CommandError("Instance could not be registered")
)
else:
self.stdout.write(
self.style.SUCCESS(

View File

@@ -1,4 +1,4 @@
# Generated by Django 4.2.7 on 2023-11-29 14:39
# Generated by Django 4.2.7 on 2023-12-06 06:49
from django.conf import settings
from django.db import migrations, models
@@ -34,6 +34,7 @@ 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')),
],
@@ -53,6 +54,7 @@ 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')),
],
@@ -70,6 +72,7 @@ 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')),

View File

@@ -30,6 +30,7 @@ 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"
@@ -47,6 +48,7 @@ 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"]
@@ -61,6 +63,7 @@ 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"

View File

@@ -4,9 +4,7 @@ from plane.license.api.views import (
InstanceEndpoint,
InstanceAdminEndpoint,
InstanceConfigurationEndpoint,
AdminMagicSignInGenerateEndpoint,
AdminSetupMagicSignInEndpoint,
AdminSetUserPasswordEndpoint,
InstanceAdminSignInEndpoint,
SignUpScreenVisitedEndpoint,
)
@@ -32,19 +30,9 @@ urlpatterns = [
name="instance-configuration",
),
path(
"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",
"instances/admins/sign-in/",
InstanceAdminSignInEndpoint.as_view(),
name="instance-admin-sign-in",
),
path(
"instances/admins/sign-up-screen-visited/",

View File

@@ -1,63 +1,71 @@
# Python imports
import os
# Django imports
from django.conf import settings
# Module imports
from plane.license.models import InstanceConfiguration
from plane.license.utils.encryption import decrypt_data
# Helper function to return value from the passed key
def get_configuration_value(query, key, default=None):
for item in query:
if item["key"] == key:
return item.get("value", default)
return default
def get_configuration_value(keys):
environment_list = []
if settings.SKIP_ENV_VAR:
# Get the configurations
instance_configuration = InstanceConfiguration.objects.values(
"key", "value", "is_encrypted"
)
for key in keys:
for item in instance_configuration:
if key.get("key") == item.get("key"):
if item.get("is_encrypted", False):
environment_list.append(decrypt_data(item.get("value")))
else:
environment_list.append(item.get("value"))
break
else:
environment_list.append(key.get("default"))
else:
# Get the configuration from os
for key in keys:
environment_list.append(os.environ.get(key.get("key"), key.get("default")))
return tuple(environment_list)
def get_email_configuration(instance_configuration):
# Get the configuration variables
EMAIL_HOST_USER = get_configuration_value(
instance_configuration,
"EMAIL_HOST_USER",
os.environ.get("EMAIL_HOST_USER", None),
)
EMAIL_HOST_PASSWORD = get_configuration_value(
instance_configuration,
"EMAIL_HOST_PASSWORD",
os.environ.get("EMAIL_HOST_PASSWORD", None),
)
EMAIL_HOST = get_configuration_value(
instance_configuration,
"EMAIL_HOST",
os.environ.get("EMAIL_HOST", None),
)
EMAIL_FROM = get_configuration_value(
instance_configuration,
"EMAIL_FROM",
os.environ.get("EMAIL_FROM", None),
)
EMAIL_USE_TLS = get_configuration_value(
instance_configuration,
"EMAIL_USE_TLS",
os.environ.get("EMAIL_USE_TLS", "1"),
)
EMAIL_PORT = get_configuration_value(
instance_configuration,
"EMAIL_PORT",
587,
)
EMAIL_FROM = get_configuration_value(
instance_configuration,
"EMAIL_FROM",
os.environ.get("EMAIL_FROM", "Team Plane <team@mailer.plane.so>"),
)
def get_email_configuration():
return (
EMAIL_HOST,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
EMAIL_PORT,
EMAIL_USE_TLS,
EMAIL_FROM,
get_configuration_value(
[
{
"key": "EMAIL_HOST",
"default": os.environ.get("EMAIL_HOST"),
},
{
"key": "EMAIL_HOST_USER",
"default": os.environ.get("EMAIL_HOST_USER"),
},
{
"key": "EMAIL_HOST_PASSWORD",
"default": os.environ.get("EMAIL_HOST_PASSWORD"),
},
{
"key": "EMAIL_PORT",
"default": os.environ.get("EMAIL_PORT", 587),
},
{
"key": "EMAIL_USE_TLS",
"default": os.environ.get("EMAIL_USE_TLS", "1"),
},
{
"key": "EMAIL_FROM",
"default": os.environ.get("EMAIL_FROM", "Team Plane <team@mailer.plane.so>"),
},
]
)
)

View File

@@ -48,7 +48,6 @@ INSTALLED_APPS = [
"rest_framework.authtoken",
"rest_framework_simplejwt.token_blacklist",
"corsheaders",
"taggit",
"django_celery_beat",
"storages",
]
@@ -110,7 +109,9 @@ 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:
@@ -326,8 +327,10 @@ USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1
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")
INSTANCE_KEY = os.environ.get(
"INSTANCE_KEY", "ae6517d563dfc13d8270bd45cf17b08f70b37d989128a9dab46ff687603333c3"
)
# Skip environment variable configuration
SKIP_ENV_VAR = os.environ.get("SKIP_ENV_VAR", "1") == "1"

View File

@@ -1,14 +1,9 @@
# 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
@@ -19,8 +14,6 @@ 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
@@ -39,3 +32,5 @@ dj-database-url==2.1.0
posthog==3.0.2
cryptography==41.0.5
lxml==4.9.3
boto3==1.28.40

View File

@@ -1,9 +1,3 @@
-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

View File

@@ -67,7 +67,7 @@ services:
web:
<<: *app-env
platform: linux/amd64
image: makeplane/plane-frontend:${APP_RELEASE:-latest}
image: makeplane/plane-frontend-ce:${APP_RELEASE:-latest}
restart: unless-stopped
command: /usr/local/bin/start.sh web/server.js web
deploy:
@@ -79,7 +79,7 @@ services:
space:
<<: *app-env
platform: linux/amd64
image: makeplane/plane-space:${APP_RELEASE:-latest}
image: makeplane/plane-space-ce:${APP_RELEASE:-latest}
restart: unless-stopped
command: /usr/local/bin/start.sh space/server.js space
deploy:
@@ -92,7 +92,7 @@ services:
api:
<<: *app-env
platform: linux/amd64
image: makeplane/plane-backend:${APP_RELEASE:-latest}
image: makeplane/plane-backend-ce:${APP_RELEASE:-latest}
restart: unless-stopped
command: ./bin/takeoff
deploy:
@@ -104,7 +104,7 @@ services:
worker:
<<: *app-env
platform: linux/amd64
image: makeplane/plane-backend:${APP_RELEASE:-latest}
image: makeplane/plane-backend-ce:${APP_RELEASE:-latest}
restart: unless-stopped
command: ./bin/worker
depends_on:
@@ -115,7 +115,7 @@ services:
beat-worker:
<<: *app-env
platform: linux/amd64
image: makeplane/plane-backend:${APP_RELEASE:-latest}
image: makeplane/plane-backend-ce:${APP_RELEASE:-latest}
restart: unless-stopped
command: ./bin/beat
depends_on:
@@ -150,7 +150,7 @@ services:
proxy:
<<: *app-env
platform: linux/amd64
image: makeplane/plane-proxy:${APP_RELEASE:-latest}
image: makeplane/plane-proxy-ce:${APP_RELEASE:-latest}
ports:
- ${NGINX_PORT}:80
depends_on:

View File

@@ -1,125 +0,0 @@
#!/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

View File

@@ -30,7 +30,7 @@
"turbo": "^1.10.16"
},
"resolutions": {
"@types/react": "18.2.39"
"@types/react": "18.2.42"
},
"packageManager": "yarn@1.22.19"
}

View File

@@ -17,7 +17,7 @@
}
},
"scripts": {
"build": "tsup",
"build": "tsup --minify",
"dev": "tsup --watch",
"check-types": "tsc --noEmit",
"format": "prettier --write \"**/*.{ts,tsx,md}\""
@@ -28,8 +28,9 @@
"react-dom": "18.2.0"
},
"dependencies": {
"@tiptap/core": "^2.1.7",
"@plane/editor-types": "*",
"@tiptap/core": "^2.1.7",
"@tiptap/extension-blockquote": "^2.1.13",
"@tiptap/extension-code-block-lowlight": "^2.1.12",
"@tiptap/extension-color": "^2.1.11",
"@tiptap/extension-image": "^2.1.7",
@@ -61,12 +62,12 @@
"tiptap-markdown": "^0.8.2"
},
"devDependencies": {
"eslint": "^7.32.0",
"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",
"@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",
"tailwind-config-custom": "*",
"tsconfig": "*",
"tsup": "^7.2.0",
@@ -79,4 +80,4 @@
"nextjs",
"react"
]
}
}

View File

@@ -15,8 +15,8 @@ export { EditorContainer } from "./ui/components/editor-container";
export { EditorContentWrapper } from "./ui/components/editor-content";
// hooks
export { useEditor } from "./ui/hooks/useEditor";
export { useReadOnlyEditor } from "./ui/hooks/useReadOnlyEditor";
export { useEditor } from "./ui/hooks/use-editor";
export { useReadOnlyEditor } from "./ui/hooks/use-read-only-editor";
// helper items
export * from "./ui/menus/menu-items";

View File

@@ -1,4 +1,5 @@
import { Editor } from "@tiptap/react";
import { useState } from "react";
import Moveable from "react-moveable";
export const ImageResizer = ({ editor }: { editor: Editor }) => {
@@ -17,6 +18,8 @@ export const ImageResizer = ({ editor }: { editor: Editor }) => {
}
};
const [aspectRatio, setAspectRatio] = useState(1);
return (
<>
<Moveable
@@ -28,9 +31,29 @@ 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) => {
delta[0] && (target!.style.width = `${width}px`);
delta[1] && (target!.style.height = `${height}px`);
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`;
}
}}
onResizeEnd={() => {
updateMediaSize();

View File

@@ -20,6 +20,7 @@ import { Mentions } from "../mentions";
import { CustomKeymap } from "./keymap";
import { CustomCodeBlock } from "./code";
import { CustomQuoteExtension } from "./quote";
import { ListKeymap } from "./custom-list-keymap";
import {
IMentionSuggestion,
@@ -34,7 +35,7 @@ export const CoreEditorExtensions = (
},
deleteFile: DeleteImage,
restoreFile: RestoreImage,
cancelUploadImage?: () => any,
cancelUploadImage?: () => any
) => [
StarterKit.configure({
bulletList: {
@@ -52,11 +53,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,
@@ -65,6 +66,9 @@ export const CoreEditorExtensions = (
width: 2,
},
}),
CustomQuoteExtension.configure({
HTMLAttributes: { className: "border-l-4 border-custom-border-300" },
}),
CustomKeymap,
ListKeymap,
TiptapLink.configure({
@@ -108,6 +112,6 @@ export const CoreEditorExtensions = (
Mentions(
mentionConfig.mentionSuggestions,
mentionConfig.mentionHighlights,
false,
false
),
];

View File

@@ -0,0 +1,26 @@
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;
},
};
},
});

View File

@@ -4,7 +4,6 @@ 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,
@@ -15,6 +14,10 @@ import {
interface CustomEditorProps {
uploadFile: UploadImage;
restoreFile: RestoreImage;
rerenderOnPropsChange?: {
id: string;
description_html: string;
};
deleteFile: DeleteImage;
cancelUploadImage?: () => any;
setIsSubmitting?: (
@@ -38,6 +41,7 @@ export const useEditor = ({
cancelUploadImage,
editorProps = {},
value,
rerenderOnPropsChange,
extensions = [],
onStart,
onChange,
@@ -78,11 +82,9 @@ export const useEditor = ({
onChange?.(editor.getJSON(), getTrimmedHTML(editor.getHTML()));
},
},
[],
[rerenderOnPropsChange],
);
useInitializedContent(editor, value);
const editorRef: MutableRefObject<Editor | null> = useRef(null);
editorRef.current = editor;

View File

@@ -1,12 +1,7 @@
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
import {
useImperativeHandle,
useRef,
MutableRefObject,
useEffect,
} from "react";
import { CoreReadOnlyEditorExtensions } from "../../ui/read-only/extensions";
import { CoreReadOnlyEditorProps } from "../../ui/read-only/props";
import { useImperativeHandle, useRef, MutableRefObject } from "react";
import { CoreReadOnlyEditorExtensions } from "../read-only/extensions";
import { CoreReadOnlyEditorProps } from "../read-only/props";
import { EditorProps } from "@tiptap/pm/view";
import { IMentionSuggestion } from "@plane/editor-types";
@@ -15,6 +10,10 @@ interface CustomReadOnlyEditorProps {
forwardedRef?: any;
extensions?: any;
editorProps?: EditorProps;
rerenderOnPropsChange?: {
id: string;
description_html: string;
};
mentionHighlights?: string[];
mentionSuggestions?: IMentionSuggestion[];
}
@@ -24,33 +23,29 @@ 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,
const editor = useCustomEditor(
{
editable: false,
content:
typeof value === "string" && value.trim() !== "" ? value : "<p></p>",
editorProps: {
...CoreReadOnlyEditorProps,
...editorProps,
},
extensions: [
...CoreReadOnlyEditorExtensions({
mentionSuggestions: mentionSuggestions ?? [],
mentionHighlights: mentionHighlights ?? [],
}),
...extensions,
],
},
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]);
[rerenderOnPropsChange],
);
const editorRef: MutableRefObject<Editor | null> = useRef(null);
editorRef.current = editor;

View File

@@ -1,19 +0,0 @@
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]);
};

View File

@@ -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/useEditor";
import { useEditor } from "./hooks/use-editor";
import { EditorContainer } from "../ui/components/editor-container";
import { EditorContentWrapper } from "../ui/components/editor-content";
import {

View File

@@ -16,7 +16,7 @@
}
},
"scripts": {
"build": "tsup",
"build": "tsup --minify",
"dev": "tsup --watch",
"check-types": "tsc --noEmit",
"format": "prettier --write \"**/*.{ts,tsx,md}\""
@@ -28,20 +28,27 @@
"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"
"react-popper": "^2.3.0",
"tippy.js": "^6.3.7",
"uuid": "^9.0.1"
},
"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": "*",
@@ -56,4 +63,4 @@
"nextjs",
"react"
]
}
}

View File

@@ -1,4 +1,8 @@
import { HeadingComp, SubheadingComp } from "./heading-component";
import {
HeadingComp,
HeadingThreeComp,
SubheadingComp,
} from "./heading-component";
import { IMarking } from "..";
import { Editor } from "@tiptap/react";
import { scrollSummary } from "../utils/editor-summary-utils";
@@ -22,11 +26,16 @@ 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)}
/>
),
)
) : (

View File

@@ -1,7 +1,8 @@
import { Editor } from "@tiptap/react";
import { Archive, Info, Lock } from "lucide-react";
import { IMarking, UploadImage } from "..";
import { Archive, RefreshCw, Lock } from "lucide-react";
import { IMarking } from "..";
import { FixedMenu } from "../menu";
import { UploadImage } from "@plane/editor-types";
import { DocumentDetails } from "../types/editor-types";
import { AlertLabel } from "./alert-label";
import {
@@ -26,6 +27,7 @@ interface IEditorHeader {
isSubmitting: "submitting" | "submitted" | "saved",
) => void;
documentDetails: DocumentDetails;
isSubmitting?: "submitting" | "submitted" | "saved";
}
export const EditorHeader = (props: IEditorHeader) => {
@@ -42,6 +44,7 @@ export const EditorHeader = (props: IEditorHeader) => {
KanbanMenuOptions,
isArchived,
isLocked,
isSubmitting,
} = props;
return (
@@ -82,6 +85,21 @@ 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>

View File

@@ -29,3 +29,19 @@ 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>
);

View File

@@ -48,7 +48,7 @@ export const InfoPopover: React.FC<Props> = (props) => {
onMouseEnter={() => setIsPopoverOpen(true)}
onMouseLeave={() => setIsPopoverOpen(false)}
>
<button type="button" ref={setReferenceElement} className="block mt-1.5">
<button type="button" ref={setReferenceElement} className="block">
<Info className="h-3.5 w-3.5" />
</button>
{isPopoverOpen && (

View File

@@ -1,13 +1,28 @@
import { EditorContainer, EditorContentWrapper } from "@plane/editor-core";
import { Editor } from "@tiptap/react";
import { useState } from "react";
import { DocumentDetails } from "../types/editor-types";
interface IPageRenderer {
type 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 {
@@ -15,13 +30,35 @@ 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">
<h1 className="text-4xl font-bold break-words pr-5 -mt-2">
{documentDetails.title}
</h1>
{!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
/>
)}
<div className="flex flex-col h-full w-full pr-5">
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
<EditorContentWrapper

View File

@@ -39,8 +39,8 @@ const VerticalDropdownItem = ({
export const VerticalDropdownMenu = ({ items }: IVerticalDropdownMenuProps) => {
return (
<CustomMenu
maxHeight={"lg"}
className={"h-4"}
maxHeight={"md"}
className={"h-4.5 mt-1"}
placement={"bottom-start"}
optionsClassName={
"border-custom-border border-r border-solid transition-all duration-200 ease-in-out "

View File

@@ -1,28 +1,56 @@
import Placeholder from "@tiptap/extension-placeholder";
import { SlashCommand } from "@plane/editor-extensions";
import { IssueWidgetExtension } from "./widgets/IssueEmbedWidget";
import { UploadImage } from "@plane/editor-types";
import { DragAndDrop } 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";
export const DocumentEditorExtensions = (
uploadFile: UploadImage,
issueEmbedConfig?: IIssueEmbedConfig,
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved",
) => void,
) => [
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...";
) => {
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();
},
},
includeChildren: true,
}),
];
];
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 : []),
];
};

View File

@@ -0,0 +1,56 @@
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,
},
});
};

View File

@@ -0,0 +1,38 @@
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,
}),
];
},
});

View File

@@ -0,0 +1,18 @@
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;
};
};

View File

@@ -0,0 +1,279 @@
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);
},
};
};

View File

@@ -0,0 +1,12 @@
import { IssueWidget } from "./issue-widget-node";
import { IIssueEmbedConfig } from "./types";
interface IssueWidgetExtensionProps {
issueEmbedConfig?: IIssueEmbedConfig;
}
export const IssueWidgetExtension = ({
issueEmbedConfig,
}: IssueWidgetExtensionProps) => IssueWidget.configure({
issueEmbedConfig,
});

View File

@@ -0,0 +1,89 @@
// @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;

View File

@@ -0,0 +1,68 @@
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)];
},
});

View File

@@ -0,0 +1,9 @@
export interface IEmbedConfig {
issueEmbedConfig: IIssueEmbedConfig;
}
export interface IIssueEmbedConfig {
fetchIssue: (issueId: string) => Promise<any>;
clickAction: (issueId: string, issueTitle: string) => void;
issues: Array<any>;
}

View File

@@ -10,18 +10,26 @@ 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 === 1 ||
node.attrs.level === 2 ||
node.attrs.level === 3) &&
node.content
) {
tempMarkings.push({
type: "heading",
level: node.attrs.level,
text: node.content[0].text,
sequence: node.attrs.level === 1 ? ++h1Sequence : ++h2Sequence,
sequence:
node.attrs.level === 1
? ++h1Sequence
: node.attrs.level === 2
? ++h2Sequence
: ++h3Sequence,
});
}
});

View File

@@ -14,14 +14,30 @@ 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;
@@ -30,10 +46,15 @@ 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>;
@@ -62,11 +83,17 @@ 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();
@@ -88,8 +115,14 @@ const DocumentEditor = ({
value,
uploadFile,
deleteFile,
cancelUploadImage,
rerenderOnPropsChange,
forwardedRef,
extensions: DocumentEditorExtensions(uploadFile, setIsSubmitting),
extensions: DocumentEditorExtensions(
uploadFile,
embedConfig?.issueEmbedConfig,
setIsSubmitting,
),
});
if (!editor) {
@@ -102,7 +135,9 @@ const DocumentEditor = ({
duplicationConfig: duplicationConfig,
pageLockConfig: pageLockConfig,
pageArchiveConfig: pageArchiveConfig,
onActionCompleteHandler,
});
const editorClassNames = getEditorClassNames({
noBorder: true,
borderOnFocus: false,
@@ -126,6 +161,7 @@ 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">
@@ -137,10 +173,12 @@ 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" />

View File

@@ -4,6 +4,8 @@ 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 {
@@ -15,6 +17,10 @@ import { getMenuOptions } from "../utils/menu-options";
interface IDocumentReadOnlyEditor {
value: string;
rerenderOnPropsChange?: {
id: string;
description_html: string;
};
noBorder: boolean;
borderOnFocus: boolean;
customClassName: string;
@@ -22,6 +28,12 @@ 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 {
@@ -43,6 +55,9 @@ const DocumentReadOnlyEditor = ({
pageDuplicationConfig,
pageLockConfig,
pageArchiveConfig,
embedConfig,
rerenderOnPropsChange,
onActionCompleteHandler,
}: DocumentReadOnlyEditorProps) => {
const router = useRouter();
const [sidePeekVisible, setSidePeekVisible] = useState(true);
@@ -51,13 +66,17 @@ const DocumentReadOnlyEditor = ({
const editor = useReadOnlyEditor({
value,
forwardedRef,
rerenderOnPropsChange,
extensions: [
IssueWidgetExtension({ issueEmbedConfig: embedConfig?.issueEmbedConfig }),
],
});
useEffect(() => {
if (editor) {
updateMarkings(editor.getJSON());
}
}, [editor?.getJSON()]);
}, [editor]);
if (!editor) {
return null;
@@ -75,6 +94,7 @@ const DocumentReadOnlyEditor = ({
pageArchiveConfig: pageArchiveConfig,
pageLockConfig: pageLockConfig,
duplicationConfig: pageDuplicationConfig,
onActionCompleteHandler,
});
return (
@@ -101,6 +121,8 @@ const DocumentReadOnlyEditor = ({
</div>
<div className="h-full w-full">
<PageRenderer
updatePageTitle={() => Promise.resolve()}
readonly={true}
editor={editor}
editorClassNames={editorClassNames}
documentDetails={documentDetails}

View File

@@ -25,6 +25,11 @@ export interface MenuOptionsProps {
duplicationConfig?: IDuplicationConfig;
pageLockConfig?: IPageLockConfig;
pageArchiveConfig?: IPageArchiveConfig;
onActionCompleteHandler: (action: {
title: string;
message: string;
type: "success" | "error" | "warning" | "info";
}) => void;
}
export const getMenuOptions = ({
@@ -33,13 +38,21 @@ export const getMenuOptions = ({
duplicationConfig,
pageLockConfig,
pageArchiveConfig,
onActionCompleteHandler,
}: MenuOptionsProps) => {
const KanbanMenuOptions: IVerticalDropdownItemProps[] = [
{
key: 1,
type: "copy_markdown",
Icon: ClipboardIcon,
action: () => copyMarkdownToClipboard(editor),
action: () => {
onActionCompleteHandler({
title: "Markdown Copied",
message: "Page Copied as Markdown",
type: "success",
});
copyMarkdownToClipboard(editor);
},
label: "Copy markdown",
},
// {
@@ -53,7 +66,14 @@ export const getMenuOptions = ({
key: 3,
type: "copy_page_link",
Icon: Link,
action: () => CopyPageLink(),
action: () => {
onActionCompleteHandler({
title: "Link Copied",
message: "Link to the page has been copied to clipboard",
type: "success",
});
CopyPageLink();
},
label: "Copy page link",
},
];
@@ -64,7 +84,25 @@ export const getMenuOptions = ({
key: KanbanMenuOptions.length++,
type: "duplicate_page",
Icon: Copy,
action: duplicationConfig.action,
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",
});
});
},
label: "Make a copy",
});
}
@@ -75,7 +113,25 @@ 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: pageLockConfig.action,
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",
});
});
},
});
}
@@ -86,7 +142,25 @@ 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: pageArchiveConfig.action,
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",
});
});
},
});
}

View File

@@ -17,7 +17,7 @@
}
},
"scripts": {
"build": "tsup",
"build": "tsup --minify",
"dev": "tsup --watch",
"check-types": "tsc --noEmit"
},
@@ -41,8 +41,8 @@
},
"devDependencies": {
"@types/node": "18.15.3",
"@types/react": "^18.2.39",
"@types/react-dom": "^18.2.14",
"@types/react": "^18.2.42",
"@types/react-dom": "^18.2.17",
"eslint": "^7.32.0",
"postcss": "^8.4.29",
"tailwind-config-custom": "*",
@@ -57,4 +57,4 @@
"nextjs",
"react"
]
}
}

View File

@@ -5,7 +5,7 @@ import { PluginKey, NodeSelection, Plugin } from "@tiptap/pm/state";
import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
function createDragHandleElement(): HTMLElement {
let dragHandleElement = document.createElement("div");
const dragHandleElement = document.createElement("div");
dragHandleElement.draggable = true;
dragHandleElement.dataset.dragHandle = "";
dragHandleElement.classList.add("drag-handle");

View File

@@ -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 } from "@plane/editor-types";
import type { UploadImage, ISlashCommandItem, CommandProps } from "@plane/editor-types";
import {
Heading1,
Heading2,
@@ -44,11 +44,6 @@ interface CommandItemProps {
icon: ReactNode;
}
interface CommandProps {
editor: Editor;
range: Range;
}
const Command = Extension.create({
name: "slash-command",
addOptions() {
@@ -88,134 +83,146 @@ const getSuggestionItems =
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved",
) => void,
additonalOptions?: Array<ISlashCommandItem>
) =>
({ 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();
({ 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();
},
},
},
{
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: "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: "Quote",
description: "Capture a quote.",
searchTerms: ["blockquote"],
icon: <TextQuote size={18} />,
command: ({ editor, range }: CommandProps) =>
toggleBlockquote(editor, range),
},
},
].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)))
);
{
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)
})
}
return true;
});
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
};
export const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
const containerHeight = container.offsetHeight;
@@ -376,10 +383,11 @@ export const SlashCommand = (
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved",
) => void,
additonalOptions?: Array<ISlashCommandItem>,
) =>
Command.configure({
suggestion: {
items: getSuggestionItems(uploadFile, setIsSubmitting),
items: getSuggestionItems(uploadFile, setIsSubmitting, additonalOptions),
render: renderItems,
},
});

View File

@@ -17,7 +17,7 @@
}
},
"scripts": {
"build": "tsup",
"build": "tsup --minify",
"dev": "tsup --watch",
"check-types": "tsc --noEmit",
"format": "prettier --write \"**/*.{ts,tsx,md}\""
@@ -35,8 +35,8 @@
},
"devDependencies": {
"@types/node": "18.15.3",
"@types/react": "^18.2.39",
"@types/react-dom": "^18.2.14",
"@types/react": "^18.2.42",
"@types/react-dom": "^18.2.17",
"eslint": "^7.32.0",
"postcss": "^8.4.29",
"tailwind-config-custom": "*",
@@ -52,4 +52,4 @@
"nextjs",
"react"
]
}
}

View File

@@ -17,7 +17,7 @@
}
},
"scripts": {
"build": "tsup",
"build": "tsup --minify",
"dev": "tsup --watch",
"check-types": "tsc --noEmit",
"format": "prettier --write \"**/*.{ts,tsx,md}\""
@@ -38,8 +38,8 @@
},
"devDependencies": {
"@types/node": "18.15.3",
"@types/react": "^18.2.39",
"@types/react-dom": "^18.2.14",
"@types/react": "^18.2.42",
"@types/react-dom": "^18.2.17",
"eslint": "^7.32.0",
"postcss": "^8.4.29",
"react": "^18.2.0",
@@ -55,4 +55,4 @@
"nextjs",
"react"
]
}
}

View File

@@ -24,6 +24,10 @@ 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;
@@ -63,6 +67,7 @@ const RichTextEditor = ({
restoreFile,
forwardedRef,
mentionHighlights,
rerenderOnPropsChange,
mentionSuggestions,
}: RichTextEditorProps) => {
const editor = useEditor({
@@ -76,6 +81,7 @@ const RichTextEditor = ({
deleteFile,
restoreFile,
forwardedRef,
rerenderOnPropsChange,
extensions: RichTextEditorExtensions(
uploadFile,
setIsSubmitting,

View File

@@ -17,7 +17,7 @@
}
},
"scripts": {
"build": "tsup",
"build": "tsup --minify",
"dev": "tsup --watch",
"check-types": "tsc --noEmit"
},
@@ -32,6 +32,7 @@
"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",
@@ -47,4 +48,4 @@
"nextjs",
"react"
]
}
}

View File

@@ -5,3 +5,4 @@ export type {
IMentionHighlight,
IMentionSuggestion,
} from "./types/mention-suggestion";
export type { ISlashCommandItem, CommandProps } from "./types/slash-commands-suggestion"

View File

@@ -0,0 +1,15 @@
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;
}

View File

@@ -12,16 +12,16 @@
"dist/**"
],
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --external react",
"build": "tsup src/index.ts --format esm,cjs --dts --external react --minify",
"dev": "tsup src/index.ts --format esm,cjs --watch --dts --external react",
"lint": "eslint src/",
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
},
"devDependencies": {
"@types/node": "^20.5.2",
"@types/react": "^18.2.39",
"@types/react": "^18.2.42",
"@types/react-color": "^3.0.9",
"@types/react-dom": "^18.2.15",
"@types/react-dom": "^18.2.17",
"classnames": "^2.3.2",
"eslint-config-custom": "*",
"react": "^18.2.0",
@@ -38,4 +38,4 @@
"react-color": "^2.19.3",
"react-popper": "^2.3.0"
}
}
}

View File

@@ -10,4 +10,4 @@ cp ./space/.env.example ./space/.env
cp ./apiserver/.env.example ./apiserver/.env
# Generate the SECRET_KEY that will be used by django
echo -e "SECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./apiserver/.env
echo "SECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./apiserver/.env

View File

@@ -1,216 +0,0 @@
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>
</>
);
};

View File

@@ -1,115 +0,0 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import Link from "next/link";
import { useForm } from "react-hook-form";
// components
import { EmailResetPasswordForm } from "./email-reset-password-form";
// ui
import { Input, PrimaryButton } from "components/ui";
// types
type EmailPasswordFormValues = {
email: string;
password?: string;
medium?: string;
};
type Props = {
onSubmit: (formData: EmailPasswordFormValues) => Promise<void>;
};
export const EmailPasswordForm: React.FC<Props> = ({ onSubmit }) => {
const [isResettingPassword, setIsResettingPassword] = useState(false);
const router = useRouter();
const isSignUpPage = router.pathname === "/sign-up";
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<EmailPasswordFormValues>({
defaultValues: {
email: "",
password: "",
medium: "email",
},
mode: "onChange",
reValidateMode: "onChange",
});
return (
<>
<h1 className="text-center text-2xl sm:text-2.5xl font-semibold text-custom-text-100">
{isResettingPassword ? "Reset your password" : isSignUpPage ? "Sign up on Plane" : "Sign in to Plane"}
</h1>
{isResettingPassword ? (
<EmailResetPasswordForm setIsResettingPassword={setIsResettingPassword} />
) : (
<form className="space-y-4 mt-10 w-full sm:w-[360px] mx-auto" onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-1">
<Input
id="email"
type="email"
{...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",
})}
placeholder="Enter your email address..."
className="border-custom-border-300 h-[46px]"
/>
{errors.email && <div className="text-sm text-red-500">{errors.email.message}</div>}
</div>
<div className="space-y-1">
<Input
id="password"
type="password"
{...register("password", {
required: "Password is required",
})}
placeholder="Enter your password..."
className="border-custom-border-300 h-[46px]"
/>
{errors.password && <div className="text-sm text-red-500">{errors.password.message}</div>}
</div>
<div className="text-right text-xs">
{isSignUpPage ? (
<Link href="/">
<span className="text-custom-text-200 hover:text-custom-primary-100">
Already have an account? Sign in.
</span>
</Link>
) : (
<button
type="button"
onClick={() => setIsResettingPassword(true)}
className="text-custom-text-200 hover:text-custom-primary-100"
>
Forgot your password?
</button>
)}
</div>
<div>
<PrimaryButton
type="submit"
className="w-full text-center h-[46px]"
disabled={!isValid && isDirty}
loading={isSubmitting}
>
{isSignUpPage ? (isSubmitting ? "Signing up..." : "Sign up") : isSubmitting ? "Signing in..." : "Sign in"}
</PrimaryButton>
{!isSignUpPage && (
<Link href="/sign-up">
<span className="block text-custom-text-200 hover:text-custom-primary-100 text-xs mt-4">
Don{"'"}t have an account? Sign up.
</span>
</Link>
)}
</div>
</form>
)}
</>
);
};

View File

@@ -1,83 +0,0 @@
import React from "react";
import { useForm } from "react-hook-form";
// ui
import { Input } from "components/ui";
import { Button } from "@plane/ui";
// types
type Props = {
setIsResettingPassword: React.Dispatch<React.SetStateAction<boolean>>;
};
export const EmailResetPasswordForm: React.FC<Props> = ({ setIsResettingPassword }) => {
// const { setToastAlert } = useToast();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm({
defaultValues: {
email: "",
},
mode: "onChange",
reValidateMode: "onChange",
});
const forgotPassword = async (formData: any) => {
// const payload = {
// email: formData.email,
// };
// await userService
// .forgotPassword(payload)
// .then(() =>
// setToastAlert({
// type: "success",
// title: "Success!",
// message: "Password reset link has been sent to your email address.",
// })
// )
// .catch((err) => {
// if (err.status === 400)
// setToastAlert({
// type: "error",
// title: "Error!",
// message: "Please check the Email ID entered.",
// });
// else
// setToastAlert({
// type: "error",
// title: "Error!",
// message: "Something went wrong. Please try again.",
// });
// });
};
return (
<form className="mx-auto mt-10 w-full space-y-4 sm:w-[360px]" onSubmit={handleSubmit(forgotPassword)}>
<div className="space-y-1">
<Input
id="email"
type="email"
{...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",
})}
placeholder="Enter registered email address.."
className="h-[46px] border-custom-border-300"
/>
{errors.email && <div className="text-sm text-red-500">{errors.email.message}</div>}
</div>
<div className="mt-5 flex flex-col-reverse items-center gap-2 sm:flex-row">
<Button variant="neutral-primary" className="w-full" onClick={() => setIsResettingPassword(false)}>
Go Back
</Button>
<Button variant="primary" className="w-full" type="submit" loading={isSubmitting}>
{isSubmitting ? "Sending link..." : "Send reset link"}
</Button>
</div>
</form>
);
};

View File

@@ -38,7 +38,7 @@ export const GithubLoginButton: FC<GithubLoginButtonProps> = (props) => {
}, []);
return (
<div className="w-full flex justify-center items-center">
<div className="w-full">
<Link
className="w-full"
href={`https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}

View File

@@ -29,9 +29,8 @@ export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
theme: "outline",
size: "large",
logo_alignment: "center",
width: 360,
text: "signin_with",
} as any // customization attributes
} as GsiButtonConfiguration // customization attributes
);
} catch (err) {
console.log(err);
@@ -40,7 +39,7 @@ export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
(window as any)?.google?.accounts.id.prompt(); // also display the One Tap dialog
setGsiScriptLoaded(true);
}, [handleSignIn, gsiScriptLoaded]);
}, [handleSignIn, gsiScriptLoaded, clientId]);
useEffect(() => {
if ((window as any)?.google?.accounts?.id) {

View File

@@ -1,8 +1,5 @@
export * from "./email-code-form";
export * from "./email-password-form";
export * from "./email-reset-password-form";
export * from "./github-login-button";
export * from "./google-login";
export * from "./onboarding-form";
export * from "./sign-in";
export * from "./user-logged-in";
export * from "./sign-in-forms";

View File

@@ -11,9 +11,9 @@ import { USER_ROLES } from "constants/workspace";
// hooks
import useToast from "hooks/use-toast";
// services
import UserService from "services/user.service";
import { UserService } from "services/user.service";
// ui
import { Input, PrimaryButton } from "components/ui";
import { Button, Input } from "@plane/ui";
const defaultValues = {
first_name: "",
@@ -93,6 +93,7 @@ export const OnBoardingForm: React.FC<Props> = observer(({ user }) => {
<Input
id="firstName"
autoComplete="off"
className="w-full"
placeholder="Enter your first name..."
{...register("first_name", {
required: "First name is required",
@@ -105,6 +106,7 @@ export const OnBoardingForm: React.FC<Props> = observer(({ user }) => {
<Input
id="lastName"
autoComplete="off"
className="w-full"
placeholder="Enter your last name..."
{...register("last_name", {
required: "Last name is required",
@@ -173,9 +175,9 @@ export const OnBoardingForm: React.FC<Props> = observer(({ user }) => {
</div>
</div>
<PrimaryButton type="submit" size="md" disabled={!isValid} loading={isSubmitting}>
<Button variant="primary" type="submit" size="xl" disabled={!isValid} loading={isSubmitting}>
{isSubmitting ? "Updating..." : "Continue"}
</PrimaryButton>
</Button>
</form>
);
});

View File

@@ -0,0 +1,141 @@
import React, { useEffect } from "react";
import Link from "next/link";
import { Controller, useForm } from "react-hook-form";
// services
import { AuthService } from "services/authentication.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Button, Input } from "@plane/ui";
// helpers
import { checkEmailValidity } from "helpers/string.helper";
// constants
import { ESignInSteps } from "components/accounts";
type Props = {
email: string;
handleStepChange: (step: ESignInSteps) => void;
handleSignInRedirection: () => Promise<void>;
isOnboarded: boolean;
};
type TCreatePasswordFormValues = {
email: string;
password: string;
};
const defaultValues: TCreatePasswordFormValues = {
email: "",
password: "",
};
// services
const authService = new AuthService();
export const CreatePasswordForm: React.FC<Props> = (props) => {
const { email, handleSignInRedirection, isOnboarded } = props;
// toast alert
const { setToastAlert } = useToast();
// form info
const {
control,
formState: { errors, isSubmitting, isValid },
setFocus,
handleSubmit,
} = useForm<TCreatePasswordFormValues>({
defaultValues: {
...defaultValues,
email,
},
mode: "onChange",
reValidateMode: "onChange",
});
const handleCreatePassword = async (formData: TCreatePasswordFormValues) => {
const payload = {
password: formData.password,
};
await authService
.setPassword(payload)
.then(async () => {
setToastAlert({
type: "success",
title: "Success!",
message: "Password created successfully.",
});
await handleSignInRedirection();
})
.catch((err) =>
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
})
);
};
useEffect(() => {
setFocus("password");
}, [setFocus]);
return (
<>
<h1 className="text-center text-2xl sm:text-2.5xl font-medium text-onboarding-text-100">
Get on your flight deck
</h1>
<form onSubmit={handleSubmit(handleCreatePassword)} className="mt-11 sm:w-96 mx-auto space-y-4">
<Controller
control={control}
name="email"
rules={{
required: "Email is required",
validate: (value) => checkEmailValidity(value) || "Email is invalid",
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="email"
name="email"
type="email"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.email)}
placeholder="orville.wright@firstflight.com"
className="w-full h-[46px] text-onboarding-text-400 border border-onboarding-border-100 pr-12 !bg-onboarding-background-200"
disabled
/>
)}
/>
<Controller
control={control}
name="password"
rules={{
required: "Password is required",
}}
render={({ field: { value, onChange, ref } }) => (
<Input
type="password"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.password)}
placeholder="Choose password"
className="w-full h-[46px] placeholder:text-onboarding-text-400 border border-onboarding-border-100 pr-12 !bg-onboarding-background-200"
minLength={8}
/>
)}
/>
<Button type="submit" variant="primary" className="w-full" size="xl" disabled={!isValid} loading={isSubmitting}>
{isOnboarded ? "Go to board" : "Set up profile"}
</Button>
<p className="text-xs text-onboarding-text-200">
When you click the button above, you agree with our{" "}
<Link href="https://plane.so/terms-and-conditions" target="_blank" rel="noopener noreferrer">
<span className="font-semibold underline">terms and conditions of service.</span>
</Link>
</p>
</form>
</>
);
};

View File

@@ -0,0 +1,122 @@
import React, { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { XCircle } from "lucide-react";
// services
import { AuthService } from "services/authentication.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Button, Input } from "@plane/ui";
// helpers
import { checkEmailValidity } from "helpers/string.helper";
// types
import { IEmailCheckData } from "types/auth";
// constants
import { ESignInSteps } from "components/accounts";
type Props = {
handleStepChange: (step: ESignInSteps) => void;
updateEmail: (email: string) => void;
};
type TEmailFormValues = {
email: string;
};
const authService = new AuthService();
export const EmailForm: React.FC<Props> = (props) => {
const { handleStepChange, updateEmail } = props;
const { setToastAlert } = useToast();
const {
control,
formState: { errors, isSubmitting, isValid },
handleSubmit,
setFocus,
} = useForm<TEmailFormValues>({
defaultValues: {
email: "",
},
mode: "onChange",
reValidateMode: "onChange",
});
const handleFormSubmit = async (data: TEmailFormValues) => {
const payload: IEmailCheckData = {
email: data.email,
};
// update the global email state
updateEmail(data.email);
await authService
.emailCheck(payload)
.then((res) => {
// if the password has been autoset, send the user to magic sign-in
if (res.is_password_autoset) handleStepChange(ESignInSteps.UNIQUE_CODE);
// if the password has not been autoset, send them to password sign-in
else handleStepChange(ESignInSteps.PASSWORD);
})
.catch((err) =>
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
})
);
};
useEffect(() => {
setFocus("email");
}, [setFocus]);
return (
<>
<h1 className="text-center text-2xl sm:text-2.5xl font-medium text-onboarding-text-100">
Get on your flight deck
</h1>
<p className="text-center text-sm text-onboarding-text-200 mt-2.5">
Create or join a workspace. Start with your e-mail.
</p>
<form onSubmit={handleSubmit(handleFormSubmit)} className="mt-8 sm:w-96 mx-auto space-y-4">
<div className="space-y-1">
<Controller
control={control}
name="email"
rules={{
required: "Email is required",
validate: (value) => checkEmailValidity(value) || "Email is invalid",
}}
render={({ field: { value, onChange, ref } }) => (
<div className="flex items-center relative rounded-md bg-onboarding-background-200">
<Input
id="email"
name="email"
type="email"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.email)}
placeholder="orville.wright@firstflight.com"
className="w-full h-[46px] placeholder:text-onboarding-text-400 border border-onboarding-border-100 pr-12"
/>
{value.length > 0 && (
<XCircle
className="h-5 w-5 absolute stroke-custom-text-400 hover:cursor-pointer right-3"
onClick={() => onChange("")}
/>
)}
</div>
)}
/>
</div>
<Button type="submit" variant="primary" className="w-full" size="xl" disabled={!isValid} loading={isSubmitting}>
Continue
</Button>
</form>
</>
);
};

View File

@@ -0,0 +1,9 @@
export * from "./create-password";
export * from "./email-form";
export * from "./o-auth-options";
export * from "./optional-set-password";
export * from "./password";
export * from "./root";
export * from "./self-hosted-sign-in";
export * from "./set-password-link";
export * from "./unique-code";

View File

@@ -0,0 +1,86 @@
import useSWR from "swr";
import { observer } from "mobx-react-lite";
// services
import { AuthService } from "services/authentication.service";
import { AppConfigService } from "services/app-config.service";
// hooks
import useToast from "hooks/use-toast";
// components
import { GithubLoginButton, GoogleLoginButton } from "components/accounts";
type Props = {
handleSignInRedirection: () => Promise<void>;
};
// services
const authService = new AuthService();
const appConfig = new AppConfigService();
export const OAuthOptions: React.FC<Props> = observer((props) => {
const { handleSignInRedirection } = props;
// toast alert
const { setToastAlert } = useToast();
const { data: envConfig } = useSWR("APP_CONFIG", () => appConfig.envConfig());
const handleGoogleSignIn = async ({ clientId, credential }: any) => {
try {
if (clientId && credential) {
const socialAuthPayload = {
medium: "google",
credential,
clientId,
};
const response = await authService.socialAuth(socialAuthPayload);
if (response) handleSignInRedirection();
} else throw Error("Cant find credentials");
} catch (err: any) {
setToastAlert({
title: "Error signing in!",
type: "error",
message: err?.error || "Something went wrong. Please try again later or contact the support team.",
});
}
};
const handleGitHubSignIn = async (credential: string) => {
try {
if (envConfig && envConfig.github_client_id && credential) {
const socialAuthPayload = {
medium: "github",
credential,
clientId: envConfig.github_client_id,
};
const response = await authService.socialAuth(socialAuthPayload);
if (response) handleSignInRedirection();
} else throw Error("Cant find credentials");
} catch (err: any) {
setToastAlert({
title: "Error signing in!",
type: "error",
message: err?.error || "Something went wrong. Please try again later or contact the support team.",
});
}
};
return (
<>
<div className="flex sm:w-96 items-center mt-4 mx-auto">
<hr className="border-onboarding-border-100 w-full" />
<p className="text-center text-sm text-onboarding-text-400 mx-3 flex-shrink-0">Or continue with</p>
<hr className="border-onboarding-border-100 w-full" />
</div>
<div className="flex flex-col sm:flex-row items-center gap-2 pt-7 sm:w-96 mx-auto overflow-hidden">
{envConfig?.google_client_id && (
<GoogleLoginButton clientId={envConfig?.google_client_id} handleSignIn={handleGoogleSignIn} />
)}
{envConfig?.github_client_id && (
<GithubLoginButton clientId={envConfig?.github_client_id} handleSignIn={handleGitHubSignIn} />
)}
</div>
</>
);
});

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