diff --git a/.eslintrc-staged.js b/.eslintrc-staged.js
new file mode 100644
index 000000000..be20772a7
--- /dev/null
+++ b/.eslintrc-staged.js
@@ -0,0 +1,59 @@
+/**
+ * Adds three new lint plugins over the existing configuration:
+ * This is used to lint staged files only.
+ * We should remove this file once the entire codebase follows these rules.
+ */
+module.exports = {
+ root: true,
+ extends: [
+ "custom",
+ ],
+ parser: "@typescript-eslint/parser",
+ settings: {
+ "import/resolver": {
+ typescript: {},
+ node: {
+ moduleDirectory: ["node_modules", "."],
+ },
+ },
+ },
+ rules: {
+ "import/order": [
+ "error",
+ {
+ groups: ["builtin", "external", "internal", "parent", "sibling"],
+ pathGroups: [
+ {
+ pattern: "react",
+ group: "external",
+ position: "before",
+ },
+ {
+ pattern: "lucide-react",
+ group: "external",
+ position: "after",
+ },
+ {
+ pattern: "@headlessui/**",
+ group: "external",
+ position: "after",
+ },
+ {
+ pattern: "@plane/**",
+ group: "external",
+ position: "after",
+ },
+ {
+ pattern: "@/**",
+ group: "internal",
+ },
+ ],
+ pathGroupsExcludedImportTypes: ["builtin", "internal", "react"],
+ alphabetize: {
+ order: "asc",
+ caseInsensitive: true,
+ },
+ },
+ ],
+ },
+};
diff --git a/.eslintrc.js b/.eslintrc.js
index c229c0952..b1a019e35 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -4,7 +4,7 @@ module.exports = {
extends: ["custom"],
settings: {
next: {
- rootDir: ["web/", "space/"],
+ rootDir: ["web/", "space/", "admin/"],
},
},
};
diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml
index 306f92957..13c7ca221 100644
--- a/.github/workflows/build-branch.yml
+++ b/.github/workflows/build-branch.yml
@@ -22,10 +22,11 @@ jobs:
gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }}
gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }}
- build_frontend: ${{ steps.changed_files.outputs.frontend_any_changed }}
- build_space: ${{ steps.changed_files.outputs.space_any_changed }}
- build_backend: ${{ steps.changed_files.outputs.backend_any_changed }}
build_proxy: ${{ steps.changed_files.outputs.proxy_any_changed }}
+ build_apiserver: ${{ steps.changed_files.outputs.apiserver_any_changed }}
+ build_admin: ${{ steps.changed_files.outputs.admin_any_changed }}
+ build_space: ${{ steps.changed_files.outputs.space_any_changed }}
+ build_web: ${{ steps.changed_files.outputs.web_any_changed }}
steps:
- id: set_env_variables
@@ -53,8 +54,12 @@ jobs:
uses: tj-actions/changed-files@v42
with:
files_yaml: |
- frontend:
- - web/**
+ apiserver:
+ - apiserver/**
+ proxy:
+ - nginx/**
+ admin:
+ - admin/**
- packages/**
- 'package.json'
- 'yarn.lock'
@@ -67,13 +72,16 @@ jobs:
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
- backend:
- - apiserver/**
- proxy:
- - nginx/**
+ web:
+ - web/**
+ - packages/**
+ - 'package.json'
+ - 'yarn.lock'
+ - 'tsconfig.json'
+ - 'turbo.json'
- branch_build_push_frontend:
- if: ${{ needs.branch_build_setup.outputs.build_frontend == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
+ branch_build_push_web:
+ if: ${{ needs.branch_build_setup.outputs.build_web == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
@@ -124,6 +132,58 @@ jobs:
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
+ branch_build_push_admin:
+ if: ${{ needs.branch_build_setup.outputs.build_admin== 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
+ runs-on: ubuntu-20.04
+ needs: [branch_build_setup]
+ env:
+ ADMIN_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-admin:${{ needs.branch_build_setup.outputs.gh_branch_name }}
+ TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
+ BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
+ BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
+ BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
+ BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
+ steps:
+ - name: Set Admin Docker Tag
+ run: |
+ if [ "${{ github.event_name }}" == "release" ]; then
+ TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-admin:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-admin:${{ github.event.release.tag_name }}
+ elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
+ TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-admin:latest
+ else
+ TAG=${{ env.ADMIN_TAG }}
+ fi
+ echo "ADMIN_TAG=${TAG}" >> $GITHUB_ENV
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+ with:
+ driver: ${{ env.BUILDX_DRIVER }}
+ version: ${{ env.BUILDX_VERSION }}
+ endpoint: ${{ env.BUILDX_ENDPOINT }}
+
+ - name: Check out the repo
+ uses: actions/checkout@v4
+
+ - name: Build and Push Frontend to Docker Container Registry
+ uses: docker/build-push-action@v5.1.0
+ with:
+ context: .
+ file: ./admin/Dockerfile.admin
+ platforms: ${{ env.BUILDX_PLATFORMS }}
+ tags: ${{ env.ADMIN_TAG }}
+ push: true
+ env:
+ DOCKER_BUILDKIT: 1
+ DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
+ DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
+
branch_build_push_space:
if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
runs-on: ubuntu-20.04
@@ -176,8 +236,8 @@ jobs:
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
- branch_build_push_backend:
- if: ${{ needs.branch_build_setup.outputs.build_backend == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
+ branch_build_push_apiserver:
+ if: ${{ needs.branch_build_setup.outputs.build_apiserver == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
diff --git a/.github/workflows/build-test-pull-request.yml b/.github/workflows/build-test-pull-request.yml
index e0014f696..5b94b215a 100644
--- a/.github/workflows/build-test-pull-request.yml
+++ b/.github/workflows/build-test-pull-request.yml
@@ -10,42 +10,50 @@ jobs:
runs-on: ubuntu-latest
outputs:
apiserver_changed: ${{ steps.changed-files.outputs.apiserver_any_changed }}
+ admin_changed: ${{ steps.changed-files.outputs.admin_any_changed }}
+ space_changed: ${{ steps.changed-files.outputs.space_any_changed }}
web_changed: ${{ steps.changed-files.outputs.web_any_changed }}
- space_changed: ${{ steps.changed-files.outputs.deploy_any_changed }}
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Get changed files
id: changed-files
- uses: tj-actions/changed-files@v41
+ uses: tj-actions/changed-files@v44
with:
files_yaml: |
apiserver:
- apiserver/**
- web:
- - web/**
+ admin:
+ - admin/**
- packages/**
- 'package.json'
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
- deploy:
+ space:
- space/**
- packages/**
- 'package.json'
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
+ web:
+ - web/**
+ - packages/**
+ - 'package.json'
+ - 'yarn.lock'
+ - 'tsconfig.json'
+ - 'turbo.json'
lint-apiserver:
needs: get-changed-files
runs-on: ubuntu-latest
if: needs.get-changed-files.outputs.apiserver_changed == 'true'
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Set up Python
- uses: actions/setup-python@v4
+ uses: actions/setup-python@v5
with:
- python-version: '3.x' # Specify the Python version you need
+ python-version: "3.x" # Specify the Python version you need
- name: Install Pylint
run: python -m pip install ruff
- name: Install Apiserver Dependencies
@@ -53,52 +61,77 @@ jobs:
- name: Lint apiserver
run: ruff check --fix apiserver
- lint-web:
+ lint-admin:
needs: get-changed-files
- if: needs.get-changed-files.outputs.web_changed == 'true'
+ if: needs.get-changed-files.outputs.admin_changed == 'true'
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Setup Node.js
- uses: actions/setup-node@v2
+ uses: actions/setup-node@v4
with:
node-version: 18.x
- run: yarn install
- - run: yarn lint --filter=web
+ - run: yarn lint --filter=admin
lint-space:
needs: get-changed-files
if: needs.get-changed-files.outputs.space_changed == 'true'
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Setup Node.js
- uses: actions/setup-node@v2
+ uses: actions/setup-node@v4
with:
node-version: 18.x
- run: yarn install
- run: yarn lint --filter=space
- build-web:
- needs: lint-web
+ lint-web:
+ needs: get-changed-files
+ if: needs.get-changed-files.outputs.web_changed == 'true'
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Setup Node.js
- uses: actions/setup-node@v2
+ uses: actions/setup-node@v4
with:
node-version: 18.x
- run: yarn install
- - run: yarn build --filter=web
+ - run: yarn lint --filter=web
+
+ build-admin:
+ needs: lint-admin
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 18.x
+ - run: yarn install
+ - run: yarn build --filter=admin
build-space:
needs: lint-space
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Setup Node.js
- uses: actions/setup-node@v2
+ uses: actions/setup-node@v4
with:
node-version: 18.x
- run: yarn install
- run: yarn build --filter=space
+
+ build-web:
+ needs: lint-web
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 18.x
+ - run: yarn install
+ - run: yarn build --filter=web
diff --git a/.github/workflows/create-sync-pr.yml b/.github/workflows/create-sync-pr.yml
index a46fd74d2..c195f8423 100644
--- a/.github/workflows/create-sync-pr.yml
+++ b/.github/workflows/create-sync-pr.yml
@@ -64,6 +64,6 @@ jobs:
echo "Pull Request already exists: $PR_EXISTS"
else
echo "Creating new pull request"
- PR_URL=$(gh pr create --base $TARGET_BRANCH --head $SOURCE_BRANCH --title "sync: merge conflicts need to be resolved" --body "")
+ PR_URL=$(gh pr create --base $TARGET_BRANCH --head $SOURCE_BRANCH --title "sync: community changes" --body "")
echo "Pull Request created: $PR_URL"
fi
diff --git a/.github/workflows/feature-deployment.yml b/.github/workflows/feature-deployment.yml
index c5eec3cd3..e848dc36d 100644
--- a/.github/workflows/feature-deployment.yml
+++ b/.github/workflows/feature-deployment.yml
@@ -5,18 +5,24 @@ on:
inputs:
web-build:
required: false
- description: 'Build Web'
+ description: "Build Web"
type: boolean
default: true
space-build:
required: false
- description: 'Build Space'
+ description: "Build Space"
+ type: boolean
+ default: false
+ admin-build:
+ required: false
+ description: "Build Admin"
type: boolean
default: false
env:
BUILD_WEB: ${{ github.event.inputs.web-build }}
BUILD_SPACE: ${{ github.event.inputs.space-build }}
+ BUILD_ADMIN: ${{ github.event.inputs.admin-build }}
jobs:
setup-feature-build:
@@ -27,9 +33,11 @@ jobs:
run: |
echo "BUILD_WEB=$BUILD_WEB"
echo "BUILD_SPACE=$BUILD_SPACE"
+ echo "BUILD_ADMIN=$BUILD_ADMIN"
outputs:
- web-build: ${{ env.BUILD_WEB}}
+ web-build: ${{ env.BUILD_WEB}}
space-build: ${{env.BUILD_SPACE}}
+ admin-build: ${{env.BUILD_ADMIN}}
feature-build-web:
if: ${{ needs.setup-feature-build.outputs.web-build == 'true' }}
@@ -45,7 +53,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
- node-version: '18'
+ node-version: "18"
- name: Install AWS cli
run: |
sudo apt-get update
@@ -71,7 +79,7 @@ jobs:
FILE_EXPIRY=$(date -u -d "+2 days" +"%Y-%m-%dT%H:%M:%SZ")
aws s3 cp $TAR_NAME s3://${{ env.AWS_BUCKET }}/${{github.sha}}/$TAR_NAME --expires $FILE_EXPIRY
-
+
feature-build-space:
if: ${{ needs.setup-feature-build.outputs.space-build == 'true' }}
needs: setup-feature-build
@@ -81,7 +89,7 @@ jobs:
AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }}
AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}
- NEXT_PUBLIC_DEPLOY_WITH_NGINX: 1
+ NEXT_PUBLIC_SPACE_BASE_PATH: "/spaces"
NEXT_PUBLIC_API_BASE_URL: ${{ vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL }}
outputs:
do-build: ${{ needs.setup-feature-build.outputs.space-build }}
@@ -90,7 +98,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
- node-version: '18'
+ node-version: "18"
- name: Install AWS cli
run: |
sudo apt-get update
@@ -117,9 +125,60 @@ jobs:
FILE_EXPIRY=$(date -u -d "+2 days" +"%Y-%m-%dT%H:%M:%SZ")
aws s3 cp $TAR_NAME s3://${{ env.AWS_BUCKET }}/${{github.sha}}/$TAR_NAME --expires $FILE_EXPIRY
+ feature-build-admin:
+ if: ${{ needs.setup-feature-build.outputs.admin-build == 'true' }}
+ needs: setup-feature-build
+ name: Feature Build Admin
+ runs-on: ubuntu-latest
+ env:
+ AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }}
+ AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }}
+ AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}
+ NEXT_PUBLIC_ADMIN_BASE_PATH: "/god-mode"
+ NEXT_PUBLIC_API_BASE_URL: ${{ vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL }}
+ outputs:
+ do-build: ${{ needs.setup-feature-build.outputs.admin-build }}
+ s3-url: ${{ steps.build-admin.outputs.S3_PRESIGNED_URL }}
+ steps:
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "18"
+ - name: Install AWS cli
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y python3-pip
+ pip3 install awscli
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ path: plane
+ - name: Install Dependencies
+ run: |
+ cd $GITHUB_WORKSPACE/plane
+ yarn install
+ - name: Build Admin
+ id: build-admin
+ run: |
+ cd $GITHUB_WORKSPACE/plane
+ yarn build --filter=admin
+ cd $GITHUB_WORKSPACE
+
+ TAR_NAME="admin.tar.gz"
+ tar -czf $TAR_NAME ./plane
+
+ FILE_EXPIRY=$(date -u -d "+2 days" +"%Y-%m-%dT%H:%M:%SZ")
+ aws s3 cp $TAR_NAME s3://${{ env.AWS_BUCKET }}/${{github.sha}}/$TAR_NAME --expires $FILE_EXPIRY
+
feature-deploy:
- if: ${{ always() && (needs.setup-feature-build.outputs.web-build == 'true' || needs.setup-feature-build.outputs.space-build == 'true') }}
- needs: [feature-build-web, feature-build-space]
+ if: ${{ always() && (needs.setup-feature-build.outputs.web-build == 'true' || needs.setup-feature-build.outputs.space-build == 'true' || needs.setup-feature-build.outputs.admin-build == 'true') }}
+ needs:
+ [
+ setup-feature-build,
+ feature-build-web,
+ feature-build-space,
+ feature-build-admin,
+ ]
name: Feature Deploy
runs-on: ubuntu-latest
env:
@@ -164,7 +223,12 @@ jobs:
SPACE_S3_URL=$(aws s3 presign s3://${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}/${{github.sha}}/space.tar.gz --expires-in 3600)
fi
- if [ ${{ env.BUILD_WEB }} == true ] || [ ${{ env.BUILD_SPACE }} == true ]; then
+ ADMIN_S3_URL=""
+ if [ ${{ env.BUILD_ADMIN }} == true ]; then
+ ADMIN_S3_URL=$(aws s3 presign s3://${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}/${{github.sha}}/admin.tar.gz --expires-in 3600)
+ fi
+
+ if [ ${{ env.BUILD_WEB }} == true ] || [ ${{ env.BUILD_SPACE }} == true ] || [ ${{ env.BUILD_ADMIN }} == true ]; then
helm --kube-insecure-skip-tls-verify repo add feature-preview ${{ vars.FEATURE_PREVIEW_HELM_CHART_URL }}
@@ -181,6 +245,9 @@ jobs:
--set space.image=${{vars.FEATURE_PREVIEW_DOCKER_BASE}} \
--set space.enabled=${{ env.BUILD_SPACE || false }} \
--set space.artifact_url=$SPACE_S3_URL \
+ --set admin.image=${{vars.FEATURE_PREVIEW_DOCKER_BASE}} \
+ --set admin.enabled=${{ env.BUILD_ADMIN || false }} \
+ --set admin.artifact_url=$ADMIN_S3_URL \
--set shared_config.deploy_script_url=$DEPLOY_SCRIPT_URL \
--set shared_config.api_base_url=${{vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL}} \
--output json \
diff --git a/.gitignore b/.gitignore
index 3989f4356..80607b92f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -81,3 +81,7 @@ tmp/
## packages
dist
.temp/
+deploy/selfhost/plane-app/
+## Storybook
+*storybook.log
+output.css
diff --git a/.husky/pre-commit b/.husky/pre-commit
new file mode 100644
index 000000000..e69de29bb
diff --git a/.lintstagedrc.json b/.lintstagedrc.json
new file mode 100644
index 000000000..22825d771
--- /dev/null
+++ b/.lintstagedrc.json
@@ -0,0 +1,3 @@
+{
+ "*.{ts,tsx,js,jsx}": ["eslint -c ./.eslintrc-staged.js", "prettier --check"]
+}
diff --git a/Dockerfile b/Dockerfile
index 0d5951dee..ec01b2a55 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -82,7 +82,7 @@ COPY apiserver/templates templates/
RUN apk --no-cache add "bash~=5.2"
COPY apiserver/bin ./bin/
-RUN chmod +x ./bin/takeoff ./bin/worker
+RUN chmod +x ./bin/*
RUN chmod -R 777 /code
# Expose container port and run entry point script
diff --git a/README.md b/README.md
index ece8ff1e2..38ead5f99 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@
Plane
-Open-source project management that unlocks customer value.
+Open-source project management that unlocks customer value
@@ -40,22 +40,22 @@
-Meet [Plane](https://dub.sh/plane-website-readme). An open-source software development tool to manage issues, sprints, and product roadmaps with peace of mind. 🧘♀️
+Meet [Plane](https://dub.sh/plane-website-readme), an open-source project management tool to track issues, run ~sprints~ cycles, and manage product roadmaps without the chaos of managing the tool itself. 🧘♀️
-> Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/A92xrEGCge) or GitHub issues, and we will use your feedback to improve in our upcoming releases.
+> Plane is evolving every day. Your suggestions, ideas, and reported bugs help us immensely. Do not hesitate to join in the conversation on [Discord](https://discord.com/invite/A92xrEGCge) or raise a GitHub issue. We read everything and respond to most.
## ⚡ Installation
-The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account where we offer a hosted solution for users.
+The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account.
-If you want more control over your data, prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/docker-compose).
+If you would like to self-host Plane, please see our [deployment guide](https://docs.plane.so/docker-compose).
-| Installation Methods | Documentation Link |
+| Installation methods | Docs link |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Docker | [![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white)](https://docs.plane.so/self-hosting/methods/docker-compose) |
| Kubernetes | [![Kubernetes](https://img.shields.io/badge/kubernetes-%23326ce5.svg?style=for-the-badge&logo=kubernetes&logoColor=white)](https://docs.plane.so/kubernetes) |
-`Instance admin` can configure instance settings using our [God-mode](https://docs.plane.so/instance-admin) feature.
+`Instance admins` can configure instance settings with [God-mode](https://docs.plane.so/instance-admin).
## 🚀 Features
diff --git a/admin/.env.example b/admin/.env.example
new file mode 100644
index 000000000..fdeb05c4d
--- /dev/null
+++ b/admin/.env.example
@@ -0,0 +1,3 @@
+NEXT_PUBLIC_API_BASE_URL=""
+NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
+NEXT_PUBLIC_WEB_BASE_URL=""
\ No newline at end of file
diff --git a/admin/.eslintrc.js b/admin/.eslintrc.js
new file mode 100644
index 000000000..a82c768a0
--- /dev/null
+++ b/admin/.eslintrc.js
@@ -0,0 +1,52 @@
+module.exports = {
+ root: true,
+ extends: ["custom"],
+ parser: "@typescript-eslint/parser",
+ settings: {
+ "import/resolver": {
+ typescript: {},
+ node: {
+ moduleDirectory: ["node_modules", "."],
+ },
+ },
+ },
+ rules: {
+ "import/order": [
+ "error",
+ {
+ groups: ["builtin", "external", "internal", "parent", "sibling",],
+ pathGroups: [
+ {
+ pattern: "react",
+ group: "external",
+ position: "before",
+ },
+ {
+ pattern: "lucide-react",
+ group: "external",
+ position: "after",
+ },
+ {
+ pattern: "@headlessui/**",
+ group: "external",
+ position: "after",
+ },
+ {
+ pattern: "@plane/**",
+ group: "external",
+ position: "after",
+ },
+ {
+ pattern: "@/**",
+ group: "internal",
+ }
+ ],
+ pathGroupsExcludedImportTypes: ["builtin", "internal", "react"],
+ alphabetize: {
+ order: "asc",
+ caseInsensitive: true,
+ },
+ },
+ ],
+ },
+}
\ No newline at end of file
diff --git a/admin/.prettierignore b/admin/.prettierignore
new file mode 100644
index 000000000..43e8a7b8f
--- /dev/null
+++ b/admin/.prettierignore
@@ -0,0 +1,6 @@
+.next
+.vercel
+.tubro
+out/
+dis/
+build/
\ No newline at end of file
diff --git a/admin/.prettierrc b/admin/.prettierrc
new file mode 100644
index 000000000..87d988f1b
--- /dev/null
+++ b/admin/.prettierrc
@@ -0,0 +1,5 @@
+{
+ "printWidth": 120,
+ "tabWidth": 2,
+ "trailingComma": "es5"
+}
diff --git a/admin/Dockerfile.admin b/admin/Dockerfile.admin
new file mode 100644
index 000000000..ad9469110
--- /dev/null
+++ b/admin/Dockerfile.admin
@@ -0,0 +1,86 @@
+# *****************************************************************************
+# STAGE 1: Build the project
+# *****************************************************************************
+FROM node:18-alpine AS builder
+RUN apk add --no-cache libc6-compat
+WORKDIR /app
+
+RUN yarn global add turbo
+COPY . .
+
+RUN turbo prune --scope=admin --docker
+
+# *****************************************************************************
+# STAGE 2: Install dependencies & build the project
+# *****************************************************************************
+FROM node:18-alpine AS installer
+
+RUN apk add --no-cache libc6-compat
+WORKDIR /app
+
+COPY .gitignore .gitignore
+COPY --from=builder /app/out/json/ .
+COPY --from=builder /app/out/yarn.lock ./yarn.lock
+RUN yarn install --network-timeout 500000
+
+COPY --from=builder /app/out/full/ .
+COPY turbo.json turbo.json
+
+ARG NEXT_PUBLIC_API_BASE_URL=""
+ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
+
+ARG NEXT_PUBLIC_ADMIN_BASE_URL=""
+ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL
+
+ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
+ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH
+
+ARG NEXT_PUBLIC_SPACE_BASE_URL=""
+ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL
+
+ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
+ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH
+
+ARG NEXT_PUBLIC_WEB_BASE_URL=""
+ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
+
+ENV NEXT_TELEMETRY_DISABLED 1
+ENV TURBO_TELEMETRY_DISABLED 1
+
+RUN yarn turbo run build --filter=admin
+
+# *****************************************************************************
+# STAGE 3: Copy the project and start it
+# *****************************************************************************
+FROM node:18-alpine AS runner
+WORKDIR /app
+
+COPY --from=installer /app/admin/next.config.js .
+COPY --from=installer /app/admin/package.json .
+
+COPY --from=installer /app/admin/.next/standalone ./
+COPY --from=installer /app/admin/.next/static ./admin/.next/static
+COPY --from=installer /app/admin/public ./admin/public
+
+ARG NEXT_PUBLIC_API_BASE_URL=""
+ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
+
+ARG NEXT_PUBLIC_ADMIN_BASE_URL=""
+ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL
+
+ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
+ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH
+
+ARG NEXT_PUBLIC_SPACE_BASE_URL=""
+ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL
+
+ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
+ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH
+
+ARG NEXT_PUBLIC_WEB_BASE_URL=""
+ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
+
+ENV NEXT_TELEMETRY_DISABLED 1
+ENV TURBO_TELEMETRY_DISABLED 1
+
+EXPOSE 3000
\ No newline at end of file
diff --git a/admin/Dockerfile.dev b/admin/Dockerfile.dev
new file mode 100644
index 000000000..1ed84e78e
--- /dev/null
+++ b/admin/Dockerfile.dev
@@ -0,0 +1,17 @@
+FROM node:18-alpine
+RUN apk add --no-cache libc6-compat
+# Set working directory
+WORKDIR /app
+
+COPY . .
+
+RUN yarn global add turbo
+RUN yarn install
+
+ENV NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
+
+EXPOSE 3000
+
+VOLUME [ "/app/node_modules", "/app/admin/node_modules" ]
+
+CMD ["yarn", "dev", "--filter=admin"]
diff --git a/admin/app/ai/form.tsx b/admin/app/ai/form.tsx
new file mode 100644
index 000000000..cec5c0748
--- /dev/null
+++ b/admin/app/ai/form.tsx
@@ -0,0 +1,128 @@
+import { FC } from "react";
+import { useForm } from "react-hook-form";
+import { Lightbulb } from "lucide-react";
+import { IFormattedInstanceConfiguration, TInstanceAIConfigurationKeys } from "@plane/types";
+import { Button, TOAST_TYPE, setToast } from "@plane/ui";
+// components
+import { ControllerInput, TControllerInputFormField } from "@/components/common";
+// hooks
+import { useInstance } from "@/hooks/store";
+
+type IInstanceAIForm = {
+ config: IFormattedInstanceConfiguration;
+};
+
+type AIFormValues = Record;
+
+export const InstanceAIForm: FC = (props) => {
+ const { config } = props;
+ // store
+ const { updateInstanceConfigurations } = useInstance();
+ // form data
+ const {
+ handleSubmit,
+ control,
+ formState: { errors, isSubmitting },
+ } = useForm({
+ defaultValues: {
+ OPENAI_API_KEY: config["OPENAI_API_KEY"],
+ GPT_ENGINE: config["GPT_ENGINE"],
+ },
+ });
+
+ const aiFormFields: TControllerInputFormField[] = [
+ {
+ key: "GPT_ENGINE",
+ type: "text",
+ label: "GPT_ENGINE",
+ description: (
+ <>
+ Choose an OpenAI engine.{" "}
+
+ Learn more
+
+ >
+ ),
+ placeholder: "gpt-3.5-turbo",
+ error: Boolean(errors.GPT_ENGINE),
+ required: false,
+ },
+ {
+ key: "OPENAI_API_KEY",
+ type: "password",
+ label: "API key",
+ description: (
+ <>
+ You will find your API key{" "}
+
+ here.
+
+ >
+ ),
+ placeholder: "sk-asddassdfasdefqsdfasd23das3dasdcasd",
+ error: Boolean(errors.OPENAI_API_KEY),
+ required: false,
+ },
+ ];
+
+ const onSubmit = async (formData: AIFormValues) => {
+ const payload: Partial = { ...formData };
+
+ await updateInstanceConfigurations(payload)
+ .then(() =>
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: "Success",
+ message: "AI Settings updated successfully",
+ })
+ )
+ .catch((err) => console.error(err));
+ };
+
+ return (
+
+
+
+
OpenAI
+
If you use ChatGPT, this is for you.
+
+
+ {aiFormFields.map((field) => (
+
+ ))}
+
+
+
+
+
+ {isSubmitting ? "Saving..." : "Save changes"}
+
+
+
+
+
If you have a preferred AI models vendor, please get in touch with us.
+
+
+
+ );
+};
diff --git a/admin/app/ai/layout.tsx b/admin/app/ai/layout.tsx
new file mode 100644
index 000000000..0a0bacac1
--- /dev/null
+++ b/admin/app/ai/layout.tsx
@@ -0,0 +1,11 @@
+import { ReactNode } from "react";
+import { Metadata } from "next";
+import { AdminLayout } from "@/layouts/admin-layout";
+
+export const metadata: Metadata = {
+ title: "AI Settings - God Mode",
+};
+
+export default function AILayout({ children }: { children: ReactNode }) {
+ return {children} ;
+}
diff --git a/admin/app/ai/page.tsx b/admin/app/ai/page.tsx
new file mode 100644
index 000000000..0979bbabe
--- /dev/null
+++ b/admin/app/ai/page.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import { observer } from "mobx-react-lite";
+import useSWR from "swr";
+import { Loader } from "@plane/ui";
+// components
+import { PageHeader } from "@/components/core";
+// hooks
+import { useInstance } from "@/hooks/store";
+// components
+import { InstanceAIForm } from "./form";
+
+const InstanceAIPage = observer(() => {
+ // store
+ const { fetchInstanceConfigurations, formattedConfig } = useInstance();
+
+ useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
+
+ return (
+ <>
+
+
+
+
AI features for all your workspaces
+
+ Configure your AI API credentials so Plane AI features are turned on for all your workspaces.
+
+
+
+ {formattedConfig ? (
+
+ ) : (
+
+
+
+
+
+
+
+
+ )}
+
+
+ >
+ );
+});
+
+export default InstanceAIPage;
diff --git a/admin/app/authentication/components/authentication-method-card.tsx b/admin/app/authentication/components/authentication-method-card.tsx
new file mode 100644
index 000000000..1346a730e
--- /dev/null
+++ b/admin/app/authentication/components/authentication-method-card.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+import { FC } from "react";
+// helpers
+import { cn } from "helpers/common.helper";
+
+type Props = {
+ name: string;
+ description: string;
+ icon: JSX.Element;
+ config: JSX.Element;
+ disabled?: boolean;
+ withBorder?: boolean;
+};
+
+export const AuthenticationMethodCard: FC = (props) => {
+ const { name, description, icon, config, disabled = false, withBorder = true } = props;
+
+ return (
+
+
+
+
+
+ {name}
+
+
+ {description}
+
+
+
+
{config}
+
+ );
+};
diff --git a/admin/app/authentication/components/email-config-switch.tsx b/admin/app/authentication/components/email-config-switch.tsx
new file mode 100644
index 000000000..0f09cf82c
--- /dev/null
+++ b/admin/app/authentication/components/email-config-switch.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import React from "react";
+import { observer } from "mobx-react-lite";
+// hooks
+import { TInstanceAuthenticationMethodKeys } from "@plane/types";
+import { ToggleSwitch } from "@plane/ui";
+import { useInstance } from "@/hooks/store";
+// ui
+// types
+
+type Props = {
+ disabled: boolean;
+ updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
+};
+
+export const EmailCodesConfiguration: React.FC = observer((props) => {
+ const { disabled, updateConfig } = props;
+ // store
+ const { formattedConfig } = useInstance();
+ // derived values
+ const enableMagicLogin = formattedConfig?.ENABLE_MAGIC_LINK_LOGIN ?? "";
+
+ return (
+ {
+ Boolean(parseInt(enableMagicLogin)) === true
+ ? updateConfig("ENABLE_MAGIC_LINK_LOGIN", "0")
+ : updateConfig("ENABLE_MAGIC_LINK_LOGIN", "1");
+ }}
+ size="sm"
+ disabled={disabled}
+ />
+ );
+});
diff --git a/admin/app/authentication/components/github-config.tsx b/admin/app/authentication/components/github-config.tsx
new file mode 100644
index 000000000..27264d460
--- /dev/null
+++ b/admin/app/authentication/components/github-config.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import React from "react";
+import { observer } from "mobx-react-lite";
+import Link from "next/link";
+// icons
+import { Settings2 } from "lucide-react";
+// types
+import { TInstanceAuthenticationMethodKeys } from "@plane/types";
+// ui
+import { ToggleSwitch, getButtonStyling } from "@plane/ui";
+// helpers
+import { cn } from "@/helpers/common.helper";
+// hooks
+import { useInstance } from "@/hooks/store";
+
+type Props = {
+ disabled: boolean;
+ updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
+};
+
+export const GithubConfiguration: React.FC = observer((props) => {
+ const { disabled, updateConfig } = props;
+ // store
+ const { formattedConfig } = useInstance();
+ // derived values
+ const enableGithubConfig = formattedConfig?.IS_GITHUB_ENABLED ?? "";
+ const isGithubConfigured = !!formattedConfig?.GITHUB_CLIENT_ID && !!formattedConfig?.GITHUB_CLIENT_SECRET;
+
+ return (
+ <>
+ {isGithubConfigured ? (
+
+
+ Edit
+
+ {
+ Boolean(parseInt(enableGithubConfig)) === true
+ ? updateConfig("IS_GITHUB_ENABLED", "0")
+ : updateConfig("IS_GITHUB_ENABLED", "1");
+ }}
+ size="sm"
+ disabled={disabled}
+ />
+
+ ) : (
+
+
+ Configure
+
+ )}
+ >
+ );
+});
diff --git a/admin/app/authentication/components/google-config.tsx b/admin/app/authentication/components/google-config.tsx
new file mode 100644
index 000000000..9fde70dac
--- /dev/null
+++ b/admin/app/authentication/components/google-config.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import React from "react";
+import { observer } from "mobx-react-lite";
+import Link from "next/link";
+// icons
+import { Settings2 } from "lucide-react";
+// types
+import { TInstanceAuthenticationMethodKeys } from "@plane/types";
+// ui
+import { ToggleSwitch, getButtonStyling } from "@plane/ui";
+// helpers
+import { cn } from "@/helpers/common.helper";
+// hooks
+import { useInstance } from "@/hooks/store";
+
+type Props = {
+ disabled: boolean;
+ updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
+};
+
+export const GoogleConfiguration: React.FC = observer((props) => {
+ const { disabled, updateConfig } = props;
+ // store
+ const { formattedConfig } = useInstance();
+ // derived values
+ const enableGoogleConfig = formattedConfig?.IS_GOOGLE_ENABLED ?? "";
+ const isGoogleConfigured = !!formattedConfig?.GOOGLE_CLIENT_ID && !!formattedConfig?.GOOGLE_CLIENT_SECRET;
+
+ return (
+ <>
+ {isGoogleConfigured ? (
+
+
+ Edit
+
+ {
+ Boolean(parseInt(enableGoogleConfig)) === true
+ ? updateConfig("IS_GOOGLE_ENABLED", "0")
+ : updateConfig("IS_GOOGLE_ENABLED", "1");
+ }}
+ size="sm"
+ disabled={disabled}
+ />
+
+ ) : (
+
+
+ Configure
+
+ )}
+ >
+ );
+});
diff --git a/admin/app/authentication/components/index.ts b/admin/app/authentication/components/index.ts
new file mode 100644
index 000000000..d76d61f57
--- /dev/null
+++ b/admin/app/authentication/components/index.ts
@@ -0,0 +1,5 @@
+export * from "./email-config-switch";
+export * from "./password-config-switch";
+export * from "./authentication-method-card";
+export * from "./github-config";
+export * from "./google-config";
diff --git a/admin/app/authentication/components/password-config-switch.tsx b/admin/app/authentication/components/password-config-switch.tsx
new file mode 100644
index 000000000..901cce862
--- /dev/null
+++ b/admin/app/authentication/components/password-config-switch.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import React from "react";
+import { observer } from "mobx-react-lite";
+// hooks
+import { TInstanceAuthenticationMethodKeys } from "@plane/types";
+import { ToggleSwitch } from "@plane/ui";
+import { useInstance } from "@/hooks/store";
+// ui
+// types
+
+type Props = {
+ disabled: boolean;
+ updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
+};
+
+export const PasswordLoginConfiguration: React.FC = observer((props) => {
+ const { disabled, updateConfig } = props;
+ // store
+ const { formattedConfig } = useInstance();
+ // derived values
+ const enableEmailPassword = formattedConfig?.ENABLE_EMAIL_PASSWORD ?? "";
+
+ return (
+ {
+ Boolean(parseInt(enableEmailPassword)) === true
+ ? updateConfig("ENABLE_EMAIL_PASSWORD", "0")
+ : updateConfig("ENABLE_EMAIL_PASSWORD", "1");
+ }}
+ size="sm"
+ disabled={disabled}
+ />
+ );
+});
diff --git a/admin/app/authentication/github/form.tsx b/admin/app/authentication/github/form.tsx
new file mode 100644
index 000000000..75c76e7a5
--- /dev/null
+++ b/admin/app/authentication/github/form.tsx
@@ -0,0 +1,213 @@
+import { FC, useState } from "react";
+import isEmpty from "lodash/isEmpty";
+import Link from "next/link";
+import { useForm } from "react-hook-form";
+// types
+import { IFormattedInstanceConfiguration, TInstanceGithubAuthenticationConfigurationKeys } from "@plane/types";
+// ui
+import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui";
+// components
+import {
+ ConfirmDiscardModal,
+ ControllerInput,
+ CopyField,
+ TControllerInputFormField,
+ TCopyField,
+} from "@/components/common";
+// helpers
+import { API_BASE_URL, cn } from "@/helpers/common.helper";
+// hooks
+import { useInstance } from "@/hooks/store";
+
+type Props = {
+ config: IFormattedInstanceConfiguration;
+};
+
+type GithubConfigFormValues = Record;
+
+export const InstanceGithubConfigForm: FC = (props) => {
+ const { config } = props;
+ // states
+ const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false);
+ // store hooks
+ const { updateInstanceConfigurations } = useInstance();
+ // form data
+ const {
+ handleSubmit,
+ control,
+ reset,
+ formState: { errors, isDirty, isSubmitting },
+ } = useForm({
+ defaultValues: {
+ GITHUB_CLIENT_ID: config["GITHUB_CLIENT_ID"],
+ GITHUB_CLIENT_SECRET: config["GITHUB_CLIENT_SECRET"],
+ },
+ });
+
+ const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : "";
+
+ const GITHUB_FORM_FIELDS: TControllerInputFormField[] = [
+ {
+ key: "GITHUB_CLIENT_ID",
+ type: "text",
+ label: "Client ID",
+ description: (
+ <>
+ You will get this from your{" "}
+
+ GitHub OAuth application settings.
+
+ >
+ ),
+ placeholder: "70a44354520df8bd9bcd",
+ error: Boolean(errors.GITHUB_CLIENT_ID),
+ required: true,
+ },
+ {
+ key: "GITHUB_CLIENT_SECRET",
+ type: "password",
+ label: "Client secret",
+ description: (
+ <>
+ Your client secret is also found in your{" "}
+
+ GitHub OAuth application settings.
+
+ >
+ ),
+ placeholder: "9b0050f94ec1b744e32ce79ea4ffacd40d4119cb",
+ error: Boolean(errors.GITHUB_CLIENT_SECRET),
+ required: true,
+ },
+ ];
+
+ const GITHUB_SERVICE_FIELD: TCopyField[] = [
+ {
+ key: "Origin_URL",
+ label: "Origin URL",
+ url: originURL,
+ description: (
+ <>
+ We will auto-generate this. Paste this into the Authorized origin URL field{" "}
+
+ here.
+
+ >
+ ),
+ },
+ {
+ key: "Callback_URI",
+ label: "Callback URI",
+ url: `${originURL}/auth/github/callback/`,
+ description: (
+ <>
+ We will auto-generate this. Paste this into your Authorized Callback URI field{" "}
+
+ here.
+
+ >
+ ),
+ },
+ ];
+
+ const onSubmit = async (formData: GithubConfigFormValues) => {
+ const payload: Partial = { ...formData };
+
+ await updateInstanceConfigurations(payload)
+ .then((response = []) => {
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: "Success",
+ message: "Github Configuration Settings updated successfully",
+ });
+ reset({
+ GITHUB_CLIENT_ID: response.find((item) => item.key === "GITHUB_CLIENT_ID")?.value,
+ GITHUB_CLIENT_SECRET: response.find((item) => item.key === "GITHUB_CLIENT_SECRET")?.value,
+ });
+ })
+ .catch((err) => console.error(err));
+ };
+
+ const handleGoBack = (e: React.MouseEvent) => {
+ if (isDirty) {
+ e.preventDefault();
+ setIsDiscardChangesModalOpen(true);
+ }
+ };
+
+ return (
+ <>
+ setIsDiscardChangesModalOpen(false)}
+ />
+
+
+
+
Configuration
+ {GITHUB_FORM_FIELDS.map((field) => (
+
+ ))}
+
+
+
+ {isSubmitting ? "Saving..." : "Save changes"}
+
+
+ Go back
+
+
+
+
+
+
+
Service provider details
+ {GITHUB_SERVICE_FIELD.map((field) => (
+
+ ))}
+
+
+
+
+ >
+ );
+};
diff --git a/admin/app/authentication/github/page.tsx b/admin/app/authentication/github/page.tsx
new file mode 100644
index 000000000..b65b99205
--- /dev/null
+++ b/admin/app/authentication/github/page.tsx
@@ -0,0 +1,114 @@
+"use client";
+
+import { useState } from "react";
+import { observer } from "mobx-react-lite";
+import Image from "next/image";
+import { useTheme } from "next-themes";
+import useSWR from "swr";
+import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui";
+// components
+import { PageHeader } from "@/components/core";
+// helpers
+import { resolveGeneralTheme } from "@/helpers/common.helper";
+// hooks
+import { useInstance } from "@/hooks/store";
+// icons
+import githubLightModeImage from "@/public/logos/github-black.png";
+import githubDarkModeImage from "@/public/logos/github-white.png";
+// local components
+import { AuthenticationMethodCard } from "../components";
+import { InstanceGithubConfigForm } from "./form";
+
+const InstanceGithubAuthenticationPage = observer(() => {
+ // store
+ const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
+ // state
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ // theme
+ const { resolvedTheme } = useTheme();
+ // config
+ const enableGithubConfig = formattedConfig?.IS_GITHUB_ENABLED ?? "";
+
+ useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
+
+ const updateConfig = async (key: "IS_GITHUB_ENABLED", value: string) => {
+ setIsSubmitting(true);
+
+ const payload = {
+ [key]: value,
+ };
+
+ const updateConfigPromise = updateInstanceConfigurations(payload);
+
+ setPromiseToast(updateConfigPromise, {
+ loading: "Saving Configuration...",
+ success: {
+ title: "Configuration saved",
+ message: () => `Github authentication is now ${value ? "active" : "disabled"}.`,
+ },
+ error: {
+ title: "Error",
+ message: () => "Failed to save configuration",
+ },
+ });
+
+ await updateConfigPromise
+ .then(() => {
+ setIsSubmitting(false);
+ })
+ .catch((err) => {
+ console.error(err);
+ setIsSubmitting(false);
+ });
+ };
+ return (
+ <>
+
+
+
+
+ }
+ config={
+
{
+ Boolean(parseInt(enableGithubConfig)) === true
+ ? updateConfig("IS_GITHUB_ENABLED", "0")
+ : updateConfig("IS_GITHUB_ENABLED", "1");
+ }}
+ size="sm"
+ disabled={isSubmitting || !formattedConfig}
+ />
+ }
+ disabled={isSubmitting || !formattedConfig}
+ withBorder={false}
+ />
+
+
+ {formattedConfig ? (
+
+ ) : (
+
+
+
+
+
+
+
+ )}
+
+
+ >
+ );
+});
+
+export default InstanceGithubAuthenticationPage;
diff --git a/admin/app/authentication/google/form.tsx b/admin/app/authentication/google/form.tsx
new file mode 100644
index 000000000..fd2e7c73c
--- /dev/null
+++ b/admin/app/authentication/google/form.tsx
@@ -0,0 +1,211 @@
+import { FC, useState } from "react";
+import isEmpty from "lodash/isEmpty";
+import Link from "next/link";
+import { useForm } from "react-hook-form";
+// types
+import { IFormattedInstanceConfiguration, TInstanceGoogleAuthenticationConfigurationKeys } from "@plane/types";
+// ui
+import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui";
+// components
+import {
+ ConfirmDiscardModal,
+ ControllerInput,
+ CopyField,
+ TControllerInputFormField,
+ TCopyField,
+} from "@/components/common";
+// helpers
+import { API_BASE_URL, cn } from "@/helpers/common.helper";
+// hooks
+import { useInstance } from "@/hooks/store";
+
+type Props = {
+ config: IFormattedInstanceConfiguration;
+};
+
+type GoogleConfigFormValues = Record;
+
+export const InstanceGoogleConfigForm: FC = (props) => {
+ const { config } = props;
+ // states
+ const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false);
+ // store hooks
+ const { updateInstanceConfigurations } = useInstance();
+ // form data
+ const {
+ handleSubmit,
+ control,
+ reset,
+ formState: { errors, isDirty, isSubmitting },
+ } = useForm({
+ defaultValues: {
+ GOOGLE_CLIENT_ID: config["GOOGLE_CLIENT_ID"],
+ GOOGLE_CLIENT_SECRET: config["GOOGLE_CLIENT_SECRET"],
+ },
+ });
+
+ const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : "";
+
+ const GOOGLE_FORM_FIELDS: TControllerInputFormField[] = [
+ {
+ key: "GOOGLE_CLIENT_ID",
+ type: "text",
+ label: "Client ID",
+ description: (
+ <>
+ Your client ID lives in your Google API Console.{" "}
+
+ Learn more
+
+ >
+ ),
+ placeholder: "840195096245-0p2tstej9j5nc4l8o1ah2dqondscqc1g.apps.googleusercontent.com",
+ error: Boolean(errors.GOOGLE_CLIENT_ID),
+ required: true,
+ },
+ {
+ key: "GOOGLE_CLIENT_SECRET",
+ type: "password",
+ label: "Client secret",
+ description: (
+ <>
+ Your client secret should also be in your Google API Console.{" "}
+
+ Learn more
+
+ >
+ ),
+ placeholder: "GOCShX-ADp4cI0kPqav1gGCBg5bE02E",
+ error: Boolean(errors.GOOGLE_CLIENT_SECRET),
+ required: true,
+ },
+ ];
+
+ const GOOGLE_SERVICE_DETAILS: TCopyField[] = [
+ {
+ key: "Origin_URL",
+ label: "Origin URL",
+ url: originURL,
+ description: (
+
+ We will auto-generate this. Paste this into your Authorized JavaScript origins field. For this OAuth client{" "}
+
+ here.
+
+
+ ),
+ },
+ {
+ key: "Callback_URI",
+ label: "Callback URI",
+ url: `${originURL}/auth/google/callback/`,
+ description: (
+
+ We will auto-generate this. Paste this into your Authorized Redirect URI field. For this OAuth client{" "}
+
+ here.
+
+
+ ),
+ },
+ ];
+
+ const onSubmit = async (formData: GoogleConfigFormValues) => {
+ const payload: Partial = { ...formData };
+
+ await updateInstanceConfigurations(payload)
+ .then((response = []) => {
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: "Success",
+ message: "Google Configuration Settings updated successfully",
+ });
+ reset({
+ GOOGLE_CLIENT_ID: response.find((item) => item.key === "GOOGLE_CLIENT_ID")?.value,
+ GOOGLE_CLIENT_SECRET: response.find((item) => item.key === "GOOGLE_CLIENT_SECRET")?.value,
+ });
+ })
+ .catch((err) => console.error(err));
+ };
+
+ const handleGoBack = (e: React.MouseEvent) => {
+ if (isDirty) {
+ e.preventDefault();
+ setIsDiscardChangesModalOpen(true);
+ }
+ };
+
+ return (
+ <>
+ setIsDiscardChangesModalOpen(false)}
+ />
+
+
+
+
Configuration
+ {GOOGLE_FORM_FIELDS.map((field) => (
+
+ ))}
+
+
+
+ {isSubmitting ? "Saving..." : "Save changes"}
+
+
+ Go back
+
+
+
+
+
+
+
Service provider details
+ {GOOGLE_SERVICE_DETAILS.map((field) => (
+
+ ))}
+
+
+
+
+ >
+ );
+};
diff --git a/admin/app/authentication/google/page.tsx b/admin/app/authentication/google/page.tsx
new file mode 100644
index 000000000..05117dbe3
--- /dev/null
+++ b/admin/app/authentication/google/page.tsx
@@ -0,0 +1,102 @@
+"use client";
+
+import { useState } from "react";
+import { observer } from "mobx-react-lite";
+import Image from "next/image";
+import useSWR from "swr";
+import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui";
+// components
+import { PageHeader } from "@/components/core";
+// hooks
+import { useInstance } from "@/hooks/store";
+// icons
+import GoogleLogo from "@/public/logos/google-logo.svg";
+// local components
+import { AuthenticationMethodCard } from "../components";
+import { InstanceGoogleConfigForm } from "./form";
+
+const InstanceGoogleAuthenticationPage = observer(() => {
+ // store
+ const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
+ // state
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ // config
+ const enableGoogleConfig = formattedConfig?.IS_GOOGLE_ENABLED ?? "";
+
+ useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
+
+ const updateConfig = async (key: "IS_GOOGLE_ENABLED", value: string) => {
+ setIsSubmitting(true);
+
+ const payload = {
+ [key]: value,
+ };
+
+ const updateConfigPromise = updateInstanceConfigurations(payload);
+
+ setPromiseToast(updateConfigPromise, {
+ loading: "Saving Configuration...",
+ success: {
+ title: "Configuration saved",
+ message: () => `Google authentication is now ${value ? "active" : "disabled"}.`,
+ },
+ error: {
+ title: "Error",
+ message: () => "Failed to save configuration",
+ },
+ });
+
+ await updateConfigPromise
+ .then(() => {
+ setIsSubmitting(false);
+ })
+ .catch((err) => {
+ console.error(err);
+ setIsSubmitting(false);
+ });
+ };
+ return (
+ <>
+
+
+
+
}
+ config={
+
{
+ Boolean(parseInt(enableGoogleConfig)) === true
+ ? updateConfig("IS_GOOGLE_ENABLED", "0")
+ : updateConfig("IS_GOOGLE_ENABLED", "1");
+ }}
+ size="sm"
+ disabled={isSubmitting || !formattedConfig}
+ />
+ }
+ disabled={isSubmitting || !formattedConfig}
+ withBorder={false}
+ />
+
+
+ {formattedConfig ? (
+
+ ) : (
+
+
+
+
+
+
+
+ )}
+
+
+ >
+ );
+});
+
+export default InstanceGoogleAuthenticationPage;
diff --git a/admin/app/authentication/layout.tsx b/admin/app/authentication/layout.tsx
new file mode 100644
index 000000000..64506ddb4
--- /dev/null
+++ b/admin/app/authentication/layout.tsx
@@ -0,0 +1,11 @@
+import { ReactNode } from "react";
+import { Metadata } from "next";
+import { AdminLayout } from "@/layouts/admin-layout";
+
+export const metadata: Metadata = {
+ title: "Authentication Settings - God Mode",
+};
+
+export default function AuthenticationLayout({ children }: { children: ReactNode }) {
+ return {children} ;
+}
diff --git a/admin/app/authentication/page.tsx b/admin/app/authentication/page.tsx
new file mode 100644
index 000000000..25be147ca
--- /dev/null
+++ b/admin/app/authentication/page.tsx
@@ -0,0 +1,159 @@
+"use client";
+
+import { useState } from "react";
+import { observer } from "mobx-react-lite";
+import Image from "next/image";
+import { useTheme } from "next-themes";
+import useSWR from "swr";
+import { Mails, KeyRound } from "lucide-react";
+import { TInstanceConfigurationKeys } from "@plane/types";
+import { Loader, setPromiseToast } from "@plane/ui";
+// components
+import { PageHeader } from "@/components/core";
+// hooks
+// helpers
+import { resolveGeneralTheme } from "@/helpers/common.helper";
+import { useInstance } from "@/hooks/store";
+// images
+import githubLightModeImage from "@/public/logos/github-black.png";
+import githubDarkModeImage from "@/public/logos/github-white.png";
+import GoogleLogo from "@/public/logos/google-logo.svg";
+// local components
+import {
+ AuthenticationMethodCard,
+ EmailCodesConfiguration,
+ PasswordLoginConfiguration,
+ GithubConfiguration,
+ GoogleConfiguration,
+} from "./components";
+
+type TInstanceAuthenticationMethodCard = {
+ key: string;
+ name: string;
+ description: string;
+ icon: JSX.Element;
+ config: JSX.Element;
+};
+
+const InstanceAuthenticationPage = observer(() => {
+ // store
+ const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
+
+ useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
+
+ // state
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ // theme
+ const { resolvedTheme } = useTheme();
+
+ const updateConfig = async (key: TInstanceConfigurationKeys, value: string) => {
+ setIsSubmitting(true);
+
+ const payload = {
+ [key]: value,
+ };
+
+ const updateConfigPromise = updateInstanceConfigurations(payload);
+
+ setPromiseToast(updateConfigPromise, {
+ loading: "Saving Configuration...",
+ success: {
+ title: "Success",
+ message: () => "Configuration saved successfully",
+ },
+ error: {
+ title: "Error",
+ message: () => "Failed to save configuration",
+ },
+ });
+
+ await updateConfigPromise
+ .then(() => {
+ setIsSubmitting(false);
+ })
+ .catch((err) => {
+ console.error(err);
+ setIsSubmitting(false);
+ });
+ };
+
+ // Authentication methods
+ const authenticationMethodsCard: TInstanceAuthenticationMethodCard[] = [
+ {
+ key: "email-codes",
+ name: "Email codes",
+ description: "Login or sign up using codes sent via emails. You need to have email setup here and enabled.",
+ icon: ,
+ config: ,
+ },
+ {
+ key: "password-login",
+ name: "Password based login",
+ description: "Allow members to create accounts with passwords for emails to sign in.",
+ icon: ,
+ config: ,
+ },
+ {
+ key: "google",
+ name: "Google",
+ description: "Allow members to login or sign up to plane with their Google accounts.",
+ icon: ,
+ config: ,
+ },
+ {
+ key: "github",
+ name: "Github",
+ description: "Allow members to login or sign up to plane with their Github accounts.",
+ icon: (
+
+ ),
+ config: ,
+ },
+ ];
+
+ return (
+ <>
+
+
+
+
Manage authentication for your instance
+
+ Configure authentication modes for your team and restrict sign ups to be invite only.
+
+
+
+ {formattedConfig ? (
+
+
Authentication modes
+ {authenticationMethodsCard.map((method) => (
+
+ ))}
+
+ ) : (
+
+
+
+
+
+
+
+ )}
+
+
+ >
+ );
+});
+
+export default InstanceAuthenticationPage;
diff --git a/admin/app/email/email-config-form.tsx b/admin/app/email/email-config-form.tsx
new file mode 100644
index 000000000..8a18b481d
--- /dev/null
+++ b/admin/app/email/email-config-form.tsx
@@ -0,0 +1,222 @@
+import React, { FC, useMemo, useState } from "react";
+import { useForm } from "react-hook-form";
+// types
+import { IFormattedInstanceConfiguration, TInstanceEmailConfigurationKeys } from "@plane/types";
+// ui
+import { Button, CustomSelect, TOAST_TYPE, setToast } from "@plane/ui";
+// components
+import { ControllerInput, TControllerInputFormField } from "@/components/common";
+// hooks
+import { useInstance } from "@/hooks/store";
+// local components
+import { SendTestEmailModal } from "./test-email-modal";
+
+type IInstanceEmailForm = {
+ config: IFormattedInstanceConfiguration;
+};
+
+type EmailFormValues = Record;
+
+type TEmailSecurityKeys = "EMAIL_USE_TLS" | "EMAIL_USE_SSL" | "NONE";
+
+const EMAIL_SECURITY_OPTIONS: { [key in TEmailSecurityKeys]: string } = {
+ EMAIL_USE_TLS: "TLS",
+ EMAIL_USE_SSL: "SSL",
+ NONE: "No email security",
+};
+
+export const InstanceEmailForm: FC = (props) => {
+ const { config } = props;
+ // states
+ const [isSendTestEmailModalOpen, setIsSendTestEmailModalOpen] = useState(false);
+ // store hooks
+ const { updateInstanceConfigurations } = useInstance();
+ // form data
+ const {
+ handleSubmit,
+ watch,
+ setValue,
+ control,
+ formState: { errors, isValid, isDirty, isSubmitting },
+ } = useForm({
+ defaultValues: {
+ EMAIL_HOST: config["EMAIL_HOST"],
+ EMAIL_PORT: config["EMAIL_PORT"],
+ EMAIL_HOST_USER: config["EMAIL_HOST_USER"],
+ EMAIL_HOST_PASSWORD: config["EMAIL_HOST_PASSWORD"],
+ EMAIL_USE_TLS: config["EMAIL_USE_TLS"],
+ EMAIL_USE_SSL: config["EMAIL_USE_SSL"],
+ EMAIL_FROM: config["EMAIL_FROM"],
+ },
+ });
+
+ const emailFormFields: TControllerInputFormField[] = [
+ {
+ key: "EMAIL_HOST",
+ type: "text",
+ label: "Host",
+ placeholder: "email.google.com",
+ error: Boolean(errors.EMAIL_HOST),
+ required: true,
+ },
+ {
+ key: "EMAIL_PORT",
+ type: "text",
+ label: "Port",
+ placeholder: "8080",
+ error: Boolean(errors.EMAIL_PORT),
+ required: true,
+ },
+ {
+ key: "EMAIL_FROM",
+ type: "text",
+ label: "Sender email address",
+ description:
+ "This is the email address your users will see when getting emails from this instance. You will need to verify this address.",
+ placeholder: "no-reply@projectplane.so",
+ error: Boolean(errors.EMAIL_FROM),
+ required: true,
+ },
+ ];
+
+ const OptionalEmailFormFields: TControllerInputFormField[] = [
+ {
+ key: "EMAIL_HOST_USER",
+ type: "text",
+ label: "Username",
+ placeholder: "getitdone@projectplane.so",
+ error: Boolean(errors.EMAIL_HOST_USER),
+ required: false,
+ },
+ {
+ key: "EMAIL_HOST_PASSWORD",
+ type: "password",
+ label: "Password",
+ placeholder: "Password",
+ error: Boolean(errors.EMAIL_HOST_PASSWORD),
+ required: false,
+ },
+ ];
+
+ const onSubmit = async (formData: EmailFormValues) => {
+ const payload: Partial = { ...formData };
+
+ await updateInstanceConfigurations(payload)
+ .then(() =>
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: "Success",
+ message: "Email Settings updated successfully",
+ })
+ )
+ .catch((err) => console.error(err));
+ };
+
+ const useTLSValue = watch("EMAIL_USE_TLS");
+ const useSSLValue = watch("EMAIL_USE_SSL");
+ const emailSecurityKey: TEmailSecurityKeys = useMemo(() => {
+ if (useTLSValue === "1") return "EMAIL_USE_TLS";
+ if (useSSLValue === "1") return "EMAIL_USE_SSL";
+ return "NONE";
+ }, [useTLSValue, useSSLValue]);
+
+ const handleEmailSecurityChange = (key: TEmailSecurityKeys) => {
+ if (key === "EMAIL_USE_SSL") {
+ setValue("EMAIL_USE_TLS", "0");
+ setValue("EMAIL_USE_SSL", "1");
+ }
+ if (key === "EMAIL_USE_TLS") {
+ setValue("EMAIL_USE_TLS", "1");
+ setValue("EMAIL_USE_SSL", "0");
+ }
+ if (key === "NONE") {
+ setValue("EMAIL_USE_TLS", "0");
+ setValue("EMAIL_USE_SSL", "0");
+ }
+ };
+
+ return (
+
+
+
setIsSendTestEmailModalOpen(false)} />
+
+ {emailFormFields.map((field) => (
+
+ ))}
+
+
Email security
+
+ {Object.entries(EMAIL_SECURITY_OPTIONS).map(([key, value]) => (
+
+ {value}
+
+ ))}
+
+
+
+
+
+
+
+
Authentication (optional)
+
+ We recommend setting up a username password for your SMTP server
+
+
+
+
+
+ {OptionalEmailFormFields.map((field) => (
+
+ ))}
+
+
+
+
+
+ {isSubmitting ? "Saving..." : "Save changes"}
+
+ setIsSendTestEmailModalOpen(true)}
+ loading={isSubmitting}
+ disabled={!isValid}
+ >
+ Send test email
+
+
+
+ );
+};
diff --git a/admin/app/email/layout.tsx b/admin/app/email/layout.tsx
new file mode 100644
index 000000000..64f019ec9
--- /dev/null
+++ b/admin/app/email/layout.tsx
@@ -0,0 +1,15 @@
+import { ReactNode } from "react";
+import { Metadata } from "next";
+import { AdminLayout } from "@/layouts/admin-layout";
+
+interface EmailLayoutProps {
+ children: ReactNode;
+}
+
+export const metadata: Metadata = {
+ title: "Email Settings - God Mode",
+};
+
+const EmailLayout = ({ children }: EmailLayoutProps) => {children} ;
+
+export default EmailLayout;
diff --git a/admin/app/email/page.tsx b/admin/app/email/page.tsx
new file mode 100644
index 000000000..de776b175
--- /dev/null
+++ b/admin/app/email/page.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+import { observer } from "mobx-react-lite";
+import useSWR from "swr";
+import { Loader } from "@plane/ui";
+// components
+import { PageHeader } from "@/components/core";
+// hooks
+import { useInstance } from "@/hooks/store";
+// components
+import { InstanceEmailForm } from "./email-config-form";
+
+const InstanceEmailPage = observer(() => {
+ // store
+ const { fetchInstanceConfigurations, formattedConfig } = useInstance();
+
+ useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
+
+ return (
+ <>
+
+
+
+
Secure emails from your own instance
+
+ Plane can send useful emails to you and your users from your own instance without talking to the Internet.
+
+ Set it up below and please test your settings before you save them.
+ Misconfigs can lead to email bounces and errors.
+
+
+
+
+ {formattedConfig ? (
+
+ ) : (
+
+
+
+
+
+
+
+ )}
+
+
+ >
+ );
+});
+
+export default InstanceEmailPage;
diff --git a/admin/app/email/test-email-modal.tsx b/admin/app/email/test-email-modal.tsx
new file mode 100644
index 000000000..0feea4128
--- /dev/null
+++ b/admin/app/email/test-email-modal.tsx
@@ -0,0 +1,135 @@
+import React, { FC, useEffect, useState } from "react";
+import { Dialog, Transition } from "@headlessui/react";
+// ui
+import { Button, Input } from "@plane/ui";
+// services
+import { InstanceService } from "@/services/instance.service";
+
+type Props = {
+ isOpen: boolean;
+ handleClose: () => void;
+};
+
+enum ESendEmailSteps {
+ SEND_EMAIL = "SEND_EMAIL",
+ SUCCESS = "SUCCESS",
+ FAILED = "FAILED",
+}
+
+const instanceService = new InstanceService();
+
+export const SendTestEmailModal: FC = (props) => {
+ const { isOpen, handleClose } = props;
+
+ // state
+ const [receiverEmail, setReceiverEmail] = useState("");
+ const [sendEmailStep, setSendEmailStep] = useState(ESendEmailSteps.SEND_EMAIL);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState("");
+
+ // reset state
+ const resetState = () => {
+ setReceiverEmail("");
+ setSendEmailStep(ESendEmailSteps.SEND_EMAIL);
+ setIsLoading(false);
+ setError("");
+ };
+
+ useEffect(() => {
+ if (!isOpen) {
+ resetState();
+ }
+ }, [isOpen]);
+
+ const handleSubmit = async (e: React.MouseEvent) => {
+ e.preventDefault();
+
+ setIsLoading(true);
+ await instanceService
+ .sendTestEmail(receiverEmail)
+ .then(() => {
+ setSendEmailStep(ESendEmailSteps.SUCCESS);
+ })
+ .catch((error) => {
+ setError(error?.message || "Failed to send email");
+ setSendEmailStep(ESendEmailSteps.FAILED);
+ })
+ .finally(() => {
+ setIsLoading(false);
+ });
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {sendEmailStep === ESendEmailSteps.SEND_EMAIL
+ ? "Send test email"
+ : sendEmailStep === ESendEmailSteps.SUCCESS
+ ? "Email send"
+ : "Failed"}{" "}
+
+
+ {sendEmailStep === ESendEmailSteps.SEND_EMAIL && (
+
setReceiverEmail(e.target.value)}
+ placeholder="Receiver email"
+ className="w-full resize-none text-lg"
+ tabIndex={1}
+ />
+ )}
+ {sendEmailStep === ESendEmailSteps.SUCCESS && (
+
+
+ We have sent the test email to {receiverEmail}. Please check your spam folder if you cannot find
+ it.
+
+
If you still cannot find it, recheck your SMTP configuration and trigger a new test email.
+
+ )}
+ {sendEmailStep === ESendEmailSteps.FAILED &&
{error}
}
+
+
+ {sendEmailStep === ESendEmailSteps.SEND_EMAIL ? "Cancel" : "Close"}
+
+ {sendEmailStep === ESendEmailSteps.SEND_EMAIL && (
+
+ {isLoading ? "Sending email..." : "Send email"}
+
+ )}
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/admin/app/error.tsx b/admin/app/error.tsx
new file mode 100644
index 000000000..76794e04a
--- /dev/null
+++ b/admin/app/error.tsx
@@ -0,0 +1,9 @@
+"use client";
+
+export default function RootErrorPage() {
+ return (
+
+
Something went wrong.
+
+ );
+}
diff --git a/admin/app/general/form.tsx b/admin/app/general/form.tsx
new file mode 100644
index 000000000..5646084e2
--- /dev/null
+++ b/admin/app/general/form.tsx
@@ -0,0 +1,140 @@
+"use client";
+import { FC } from "react";
+import { observer } from "mobx-react-lite";
+import { Controller, useForm } from "react-hook-form";
+import { Telescope } from "lucide-react";
+// types
+import { IInstance, IInstanceAdmin } from "@plane/types";
+// ui
+import { Button, Input, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
+// components
+import { ControllerInput } from "@/components/common";
+// hooks
+import { useInstance } from "@/hooks/store";
+
+export interface IGeneralConfigurationForm {
+ instance: IInstance;
+ instanceAdmins: IInstanceAdmin[];
+}
+
+export const GeneralConfigurationForm: FC = observer((props) => {
+ const { instance, instanceAdmins } = props;
+ // hooks
+ const { updateInstanceInfo } = useInstance();
+ // form data
+ const {
+ handleSubmit,
+ control,
+ formState: { errors, isSubmitting },
+ } = useForm>({
+ defaultValues: {
+ instance_name: instance?.instance_name,
+ is_telemetry_enabled: instance?.is_telemetry_enabled,
+ },
+ });
+
+ const onSubmit = async (formData: Partial) => {
+ const payload: Partial = { ...formData };
+
+ console.log("payload", payload);
+
+ await updateInstanceInfo(payload)
+ .then(() =>
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: "Success",
+ message: "Settings updated successfully",
+ })
+ )
+ .catch((err) => console.error(err));
+ };
+
+ return (
+
+
+
Instance details
+
+
+
+
+
Email
+
+
+
+
+
Instance ID
+
+
+
+
+
+
+
Telemetry
+
+
+
+
+
+ Allow Plane to collect anonymous usage events
+
+
+ We collect usage events without any PII to analyse and improve Plane.{" "}
+
+ Know more.
+
+
+
+
+
+ (
+
+ )}
+ />
+
+
+
+
+
+
+ {isSubmitting ? "Saving..." : "Save changes"}
+
+
+
+ );
+});
diff --git a/admin/app/general/layout.tsx b/admin/app/general/layout.tsx
new file mode 100644
index 000000000..fabbe3640
--- /dev/null
+++ b/admin/app/general/layout.tsx
@@ -0,0 +1,11 @@
+import { ReactNode } from "react";
+import { Metadata } from "next";
+import { AdminLayout } from "@/layouts/admin-layout";
+
+export const metadata: Metadata = {
+ title: "General Settings - God Mode",
+};
+
+export default function GeneralLayout({ children }: { children: ReactNode }) {
+ return {children} ;
+}
diff --git a/admin/app/general/page.tsx b/admin/app/general/page.tsx
new file mode 100644
index 000000000..bab2a94fc
--- /dev/null
+++ b/admin/app/general/page.tsx
@@ -0,0 +1,31 @@
+"use client";
+import { observer } from "mobx-react-lite";
+// hooks
+import { useInstance } from "@/hooks/store";
+// components
+import { GeneralConfigurationForm } from "./form";
+
+function GeneralPage() {
+ const { instance, instanceAdmins } = useInstance();
+ console.log("instance", instance);
+ return (
+ <>
+
+
+
General settings
+
+ Change the name of your instance and instance admin e-mail addresses. Enable or disable telemetry in your
+ instance.
+
+
+
+ {instance && instanceAdmins && (
+
+ )}
+
+
+ >
+ );
+}
+
+export default observer(GeneralPage);
diff --git a/admin/app/globals.css b/admin/app/globals.css
new file mode 100644
index 000000000..eefcb1b26
--- /dev/null
+++ b/admin/app/globals.css
@@ -0,0 +1,384 @@
+@import url("https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;700;800&display=swap");
+@import url("https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@48,400,0,0&display=swap");
+
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer components {
+ .text-1\.5xl {
+ font-size: 1.375rem;
+ line-height: 1.875rem;
+ }
+
+ .text-2\.5xl {
+ font-size: 1.75rem;
+ line-height: 2.25rem;
+ }
+}
+
+@layer base {
+ html {
+ font-family: "Inter", sans-serif;
+ }
+
+ :root {
+ color-scheme: light !important;
+
+ --color-primary-10: 236, 241, 255;
+ --color-primary-20: 217, 228, 255;
+ --color-primary-30: 197, 214, 255;
+ --color-primary-40: 178, 200, 255;
+ --color-primary-50: 159, 187, 255;
+ --color-primary-60: 140, 173, 255;
+ --color-primary-70: 121, 159, 255;
+ --color-primary-80: 101, 145, 255;
+ --color-primary-90: 82, 132, 255;
+ --color-primary-100: 63, 118, 255;
+ --color-primary-200: 57, 106, 230;
+ --color-primary-300: 50, 94, 204;
+ --color-primary-400: 44, 83, 179;
+ --color-primary-500: 38, 71, 153;
+ --color-primary-600: 32, 59, 128;
+ --color-primary-700: 25, 47, 102;
+ --color-primary-800: 19, 35, 76;
+ --color-primary-900: 13, 24, 51;
+
+ --color-background-100: 255, 255, 255; /* primary bg */
+ --color-background-90: 247, 247, 247; /* secondary bg */
+ --color-background-80: 232, 232, 232; /* tertiary bg */
+
+ --color-text-100: 23, 23, 23; /* primary text */
+ --color-text-200: 58, 58, 58; /* secondary text */
+ --color-text-300: 82, 82, 82; /* tertiary text */
+ --color-text-400: 163, 163, 163; /* placeholder text */
+
+ --color-scrollbar: 163, 163, 163; /* scrollbar thumb */
+
+ --color-border-100: 245, 245, 245; /* subtle border= 1 */
+ --color-border-200: 229, 229, 229; /* subtle border- 2 */
+ --color-border-300: 212, 212, 212; /* strong border- 1 */
+ --color-border-400: 185, 185, 185; /* strong border- 2 */
+
+ --color-shadow-2xs: 0px 0px 1px 0px rgba(23, 23, 23, 0.06), 0px 1px 2px 0px rgba(23, 23, 23, 0.06),
+ 0px 1px 2px 0px rgba(23, 23, 23, 0.14);
+ --color-shadow-xs: 0px 1px 2px 0px rgba(0, 0, 0, 0.16), 0px 2px 4px 0px rgba(16, 24, 40, 0.12),
+ 0px 1px 8px -1px rgba(16, 24, 40, 0.1);
+ --color-shadow-sm: 0px 1px 4px 0px rgba(0, 0, 0, 0.01), 0px 4px 8px 0px rgba(0, 0, 0, 0.02),
+ 0px 1px 12px 0px rgba(0, 0, 0, 0.12);
+ --color-shadow-rg: 0px 3px 6px 0px rgba(0, 0, 0, 0.1), 0px 4px 4px 0px rgba(16, 24, 40, 0.08),
+ 0px 1px 12px 0px rgba(16, 24, 40, 0.04);
+ --color-shadow-md: 0px 4px 8px 0px rgba(0, 0, 0, 0.12), 0px 6px 12px 0px rgba(16, 24, 40, 0.12),
+ 0px 1px 16px 0px rgba(16, 24, 40, 0.12);
+ --color-shadow-lg: 0px 6px 12px 0px rgba(0, 0, 0, 0.12), 0px 8px 16px 0px rgba(0, 0, 0, 0.12),
+ 0px 1px 24px 0px rgba(16, 24, 40, 0.12);
+ --color-shadow-xl: 0px 0px 18px 0px rgba(0, 0, 0, 0.16), 0px 0px 24px 0px rgba(16, 24, 40, 0.16),
+ 0px 0px 52px 0px rgba(16, 24, 40, 0.16);
+ --color-shadow-2xl: 0px 8px 16px 0px rgba(0, 0, 0, 0.12), 0px 12px 24px 0px rgba(16, 24, 40, 0.12),
+ 0px 1px 32px 0px rgba(16, 24, 40, 0.12);
+ --color-shadow-3xl: 0px 12px 24px 0px rgba(0, 0, 0, 0.12), 0px 16px 32px 0px rgba(0, 0, 0, 0.12),
+ 0px 1px 48px 0px rgba(16, 24, 40, 0.12);
+ --color-shadow-4xl: 0px 8px 40px 0px rgba(0, 0, 61, 0.05), 0px 12px 32px -16px rgba(0, 0, 0, 0.05);
+
+ --color-sidebar-background-100: var(--color-background-100); /* primary sidebar bg */
+ --color-sidebar-background-90: var(--color-background-90); /* secondary sidebar bg */
+ --color-sidebar-background-80: var(--color-background-80); /* tertiary sidebar bg */
+
+ --color-sidebar-text-100: var(--color-text-100); /* primary sidebar text */
+ --color-sidebar-text-200: var(--color-text-200); /* secondary sidebar text */
+ --color-sidebar-text-300: var(--color-text-300); /* tertiary sidebar text */
+ --color-sidebar-text-400: var(--color-text-400); /* sidebar placeholder text */
+
+ --color-sidebar-border-100: var(--color-border-100); /* subtle sidebar border= 1 */
+ --color-sidebar-border-200: var(--color-border-100); /* subtle sidebar border- 2 */
+ --color-sidebar-border-300: var(--color-border-100); /* strong sidebar border- 1 */
+ --color-sidebar-border-400: var(--color-border-100); /* strong sidebar border- 2 */
+
+ --color-sidebar-shadow-2xs: var(--color-shadow-2xs);
+ --color-sidebar-shadow-xs: var(--color-shadow-xs);
+ --color-sidebar-shadow-sm: var(--color-shadow-sm);
+ --color-sidebar-shadow-rg: var(--color-shadow-rg);
+ --color-sidebar-shadow-md: var(--color-shadow-md);
+ --color-sidebar-shadow-lg: var(--color-shadow-lg);
+ --color-sidebar-shadow-xl: var(--color-shadow-xl);
+ --color-sidebar-shadow-2xl: var(--color-shadow-2xl);
+ --color-sidebar-shadow-3xl: var(--color-shadow-3xl);
+ --color-sidebar-shadow-4xl: var(--color-shadow-4xl);
+ }
+
+ [data-theme="light"],
+ [data-theme="light-contrast"] {
+ color-scheme: light !important;
+
+ --color-background-100: 255, 255, 255; /* primary bg */
+ --color-background-90: 247, 247, 247; /* secondary bg */
+ --color-background-80: 232, 232, 232; /* tertiary bg */
+ }
+
+ [data-theme="light"] {
+ --color-text-100: 23, 23, 23; /* primary text */
+ --color-text-200: 58, 58, 58; /* secondary text */
+ --color-text-300: 82, 82, 82; /* tertiary text */
+ --color-text-400: 163, 163, 163; /* placeholder text */
+
+ --color-scrollbar: 163, 163, 163; /* scrollbar thumb */
+
+ --color-border-100: 245, 245, 245; /* subtle border= 1 */
+ --color-border-200: 229, 229, 229; /* subtle border- 2 */
+ --color-border-300: 212, 212, 212; /* strong border- 1 */
+ --color-border-400: 185, 185, 185; /* strong border- 2 */
+
+ /* onboarding colors */
+ --gradient-onboarding-100: linear-gradient(106deg, #f2f6ff 29.8%, #e1eaff 99.34%);
+ --gradient-onboarding-200: linear-gradient(129deg, rgba(255, 255, 255, 0) -22.23%, rgba(255, 255, 255, 0.8) 62.98%);
+ --gradient-onboarding-300: linear-gradient(164deg, #fff 4.25%, rgba(255, 255, 255, 0.06) 93.5%);
+ --gradient-onboarding-400: linear-gradient(129deg, rgba(255, 255, 255, 0) -22.23%, rgba(255, 255, 255, 0.8) 62.98%);
+
+ --color-onboarding-text-100: 23, 23, 23;
+ --color-onboarding-text-200: 58, 58, 58;
+ --color-onboarding-text-300: 82, 82, 82;
+ --color-onboarding-text-400: 163, 163, 163;
+
+ --color-onboarding-background-100: 236, 241, 255;
+ --color-onboarding-background-200: 255, 255, 255;
+ --color-onboarding-background-300: 236, 241, 255;
+ --color-onboarding-background-400: 177, 206, 250;
+
+ --color-onboarding-border-100: 229, 229, 229;
+ --color-onboarding-border-200: 217, 228, 255;
+ --color-onboarding-border-300: 229, 229, 229, 0.5;
+
+ --color-onboarding-shadow-sm: 0px 4px 20px 0px rgba(126, 139, 171, 0.1);
+
+ /* toast theme */
+ --color-toast-success-text: 62, 155, 79;
+ --color-toast-error-text: 220, 62, 66;
+ --color-toast-warning-text: 255, 186, 24;
+ --color-toast-info-text: 51, 88, 212;
+ --color-toast-loading-text: 28, 32, 36;
+ --color-toast-secondary-text: 128, 131, 141;
+ --color-toast-tertiary-text: 96, 100, 108;
+
+ --color-toast-success-background: 253, 253, 254;
+ --color-toast-error-background: 255, 252, 252;
+ --color-toast-warning-background: 254, 253, 251;
+ --color-toast-info-background: 253, 253, 254;
+ --color-toast-loading-background: 253, 253, 254;
+
+ --color-toast-success-border: 218, 241, 219;
+ --color-toast-error-border: 255, 219, 220;
+ --color-toast-warning-border: 255, 247, 194;
+ --color-toast-info-border: 210, 222, 255;
+ --color-toast-loading-border: 224, 225, 230;
+ }
+
+ [data-theme="light-contrast"] {
+ --color-text-100: 11, 11, 11; /* primary text */
+ --color-text-200: 38, 38, 38; /* secondary text */
+ --color-text-300: 58, 58, 58; /* tertiary text */
+ --color-text-400: 115, 115, 115; /* placeholder text */
+
+ --color-scrollbar: 115, 115, 115; /* scrollbar thumb */
+
+ --color-border-100: 34, 34, 34; /* subtle border= 1 */
+ --color-border-200: 38, 38, 38; /* subtle border- 2 */
+ --color-border-300: 46, 46, 46; /* strong border- 1 */
+ --color-border-400: 58, 58, 58; /* strong border- 2 */
+ }
+
+ [data-theme="dark"],
+ [data-theme="dark-contrast"] {
+ color-scheme: dark !important;
+
+ --color-background-100: 25, 25, 25; /* primary bg */
+ --color-background-90: 32, 32, 32; /* secondary bg */
+ --color-background-80: 44, 44, 44; /* tertiary bg */
+
+ --color-shadow-2xs: 0px 0px 1px 0px rgba(0, 0, 0, 0.15), 0px 1px 3px 0px rgba(0, 0, 0, 0.5);
+ --color-shadow-xs: 0px 0px 2px 0px rgba(0, 0, 0, 0.2), 0px 2px 4px 0px rgba(0, 0, 0, 0.5);
+ --color-shadow-sm: 0px 0px 4px 0px rgba(0, 0, 0, 0.2), 0px 2px 6px 0px rgba(0, 0, 0, 0.5);
+ --color-shadow-rg: 0px 0px 6px 0px rgba(0, 0, 0, 0.2), 0px 4px 6px 0px rgba(0, 0, 0, 0.5);
+ --color-shadow-md: 0px 2px 8px 0px rgba(0, 0, 0, 0.2), 0px 4px 8px 0px rgba(0, 0, 0, 0.5);
+ --color-shadow-lg: 0px 4px 12px 0px rgba(0, 0, 0, 0.25), 0px 4px 10px 0px rgba(0, 0, 0, 0.55);
+ --color-shadow-xl: 0px 0px 14px 0px rgba(0, 0, 0, 0.25), 0px 6px 10px 0px rgba(0, 0, 0, 0.55);
+ --color-shadow-2xl: 0px 0px 18px 0px rgba(0, 0, 0, 0.25), 0px 8px 12px 0px rgba(0, 0, 0, 0.6);
+ --color-shadow-3xl: 0px 4px 24px 0px rgba(0, 0, 0, 0.3), 0px 12px 40px 0px rgba(0, 0, 0, 0.65);
+ }
+
+ [data-theme="dark"] {
+ --color-text-100: 229, 229, 229; /* primary text */
+ --color-text-200: 163, 163, 163; /* secondary text */
+ --color-text-300: 115, 115, 115; /* tertiary text */
+ --color-text-400: 82, 82, 82; /* placeholder text */
+
+ --color-scrollbar: 82, 82, 82; /* scrollbar thumb */
+
+ --color-border-100: 34, 34, 34; /* subtle border= 1 */
+ --color-border-200: 38, 38, 38; /* subtle border- 2 */
+ --color-border-300: 46, 46, 46; /* strong border- 1 */
+ --color-border-400: 58, 58, 58; /* strong border- 2 */
+
+ /* onboarding colors */
+ --gradient-onboarding-100: linear-gradient(106deg, #18191b 25.17%, #18191b 99.34%);
+ --gradient-onboarding-200: linear-gradient(129deg, rgba(47, 49, 53, 0.8) -22.23%, rgba(33, 34, 37, 0.8) 62.98%);
+ --gradient-onboarding-300: linear-gradient(167deg, rgba(47, 49, 53, 0.45) 19.22%, #212225 98.48%);
+
+ --color-onboarding-text-100: 237, 238, 240;
+ --color-onboarding-text-200: 176, 180, 187;
+ --color-onboarding-text-300: 118, 123, 132;
+ --color-onboarding-text-400: 105, 110, 119;
+
+ --color-onboarding-background-100: 54, 58, 64;
+ --color-onboarding-background-200: 40, 42, 45;
+ --color-onboarding-background-300: 40, 42, 45;
+ --color-onboarding-background-400: 67, 72, 79;
+
+ --color-onboarding-border-100: 54, 58, 64;
+ --color-onboarding-border-200: 54, 58, 64;
+ --color-onboarding-border-300: 34, 35, 38, 0.5;
+
+ --color-onboarding-shadow-sm: 0px 4px 20px 0px rgba(39, 44, 56, 0.1);
+
+ /* toast theme */
+ --color-toast-success-text: 178, 221, 181;
+ --color-toast-error-text: 206, 44, 49;
+ --color-toast-warning-text: 255, 186, 24;
+ --color-toast-info-text: 141, 164, 239;
+ --color-toast-loading-text: 255, 255, 255;
+ --color-toast-secondary-text: 185, 187, 198;
+ --color-toast-tertiary-text: 139, 141, 152;
+
+ --color-toast-success-background: 46, 46, 46;
+ --color-toast-error-background: 46, 46, 46;
+ --color-toast-warning-background: 46, 46, 46;
+ --color-toast-info-background: 46, 46, 46;
+ --color-toast-loading-background: 46, 46, 46;
+
+ --color-toast-success-border: 42, 126, 59;
+ --color-toast-error-border: 100, 23, 35;
+ --color-toast-warning-border: 79, 52, 34;
+ --color-toast-info-border: 58, 91, 199;
+ --color-toast-loading-border: 96, 100, 108;
+ }
+
+ [data-theme="dark-contrast"] {
+ --color-text-100: 250, 250, 250; /* primary text */
+ --color-text-200: 241, 241, 241; /* secondary text */
+ --color-text-300: 212, 212, 212; /* tertiary text */
+ --color-text-400: 115, 115, 115; /* placeholder text */
+
+ --color-scrollbar: 115, 115, 115; /* scrollbar thumb */
+
+ --color-border-100: 245, 245, 245; /* subtle border= 1 */
+ --color-border-200: 229, 229, 229; /* subtle border- 2 */
+ --color-border-300: 212, 212, 212; /* strong border- 1 */
+ --color-border-400: 185, 185, 185; /* strong border- 2 */
+ }
+
+ [data-theme="light"],
+ [data-theme="dark"],
+ [data-theme="light-contrast"],
+ [data-theme="dark-contrast"] {
+ --color-primary-10: 236, 241, 255;
+ --color-primary-20: 217, 228, 255;
+ --color-primary-30: 197, 214, 255;
+ --color-primary-40: 178, 200, 255;
+ --color-primary-50: 159, 187, 255;
+ --color-primary-60: 140, 173, 255;
+ --color-primary-70: 121, 159, 255;
+ --color-primary-80: 101, 145, 255;
+ --color-primary-90: 82, 132, 255;
+ --color-primary-100: 63, 118, 255;
+ --color-primary-200: 57, 106, 230;
+ --color-primary-300: 50, 94, 204;
+ --color-primary-400: 44, 83, 179;
+ --color-primary-500: 38, 71, 153;
+ --color-primary-600: 32, 59, 128;
+ --color-primary-700: 25, 47, 102;
+ --color-primary-800: 19, 35, 76;
+ --color-primary-900: 13, 24, 51;
+
+ --color-sidebar-background-100: var(--color-background-100); /* primary sidebar bg */
+ --color-sidebar-background-90: var(--color-background-90); /* secondary sidebar bg */
+ --color-sidebar-background-80: var(--color-background-80); /* tertiary sidebar bg */
+
+ --color-sidebar-text-100: var(--color-text-100); /* primary sidebar text */
+ --color-sidebar-text-200: var(--color-text-200); /* secondary sidebar text */
+ --color-sidebar-text-300: var(--color-text-300); /* tertiary sidebar text */
+ --color-sidebar-text-400: var(--color-text-400); /* sidebar placeholder text */
+
+ --color-sidebar-border-100: var(--color-border-100); /* subtle sidebar border= 1 */
+ --color-sidebar-border-200: var(--color-border-200); /* subtle sidebar border- 2 */
+ --color-sidebar-border-300: var(--color-border-300); /* strong sidebar border- 1 */
+ --color-sidebar-border-400: var(--color-border-400); /* strong sidebar border- 2 */
+ }
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+ -webkit-text-size-adjust: 100%;
+ -ms-text-size-adjust: 100%;
+ font-variant-ligatures: none;
+ -webkit-font-variant-ligatures: none;
+ text-rendering: optimizeLegibility;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-font-smoothing: antialiased;
+}
+
+body {
+ color: rgba(var(--color-text-100));
+}
+
+/* scrollbar style */
+::-webkit-scrollbar {
+ display: none;
+}
+
+.horizontal-scroll-enable {
+ overflow-x: scroll;
+}
+
+.horizontal-scroll-enable::-webkit-scrollbar {
+ display: block;
+ height: 7px;
+ width: 0;
+}
+
+.horizontal-scroll-enable::-webkit-scrollbar-track {
+ height: 7px;
+ background-color: rgba(var(--color-background-100));
+}
+
+.horizontal-scroll-enable::-webkit-scrollbar-thumb {
+ border-radius: 5px;
+ background-color: rgba(var(--color-scrollbar));
+}
+
+.vertical-scroll-enable::-webkit-scrollbar {
+ display: block;
+ width: 5px;
+}
+
+.vertical-scroll-enable::-webkit-scrollbar-track {
+ width: 5px;
+}
+
+.vertical-scroll-enable::-webkit-scrollbar-thumb {
+ border-radius: 5px;
+ background-color: rgba(var(--color-background-90));
+}
+/* end scrollbar style */
+
+/* progress bar */
+.progress-bar {
+ fill: currentColor;
+ color: rgba(var(--color-sidebar-background-100));
+}
+
+::-webkit-input-placeholder,
+::placeholder,
+:-ms-input-placeholder {
+ color: rgb(var(--color-text-400));
+}
diff --git a/admin/app/image/form.tsx b/admin/app/image/form.tsx
new file mode 100644
index 000000000..a6fe2945b
--- /dev/null
+++ b/admin/app/image/form.tsx
@@ -0,0 +1,79 @@
+import { FC } from "react";
+import { useForm } from "react-hook-form";
+import { IFormattedInstanceConfiguration, TInstanceImageConfigurationKeys } from "@plane/types";
+import { Button, TOAST_TYPE, setToast } from "@plane/ui";
+// components
+import { ControllerInput } from "@/components/common";
+// hooks
+import { useInstance } from "@/hooks/store";
+
+type IInstanceImageConfigForm = {
+ config: IFormattedInstanceConfiguration;
+};
+
+type ImageConfigFormValues = Record;
+
+export const InstanceImageConfigForm: FC = (props) => {
+ const { config } = props;
+ // store hooks
+ const { updateInstanceConfigurations } = useInstance();
+ // form data
+ const {
+ handleSubmit,
+ control,
+ formState: { errors, isSubmitting },
+ } = useForm({
+ defaultValues: {
+ UNSPLASH_ACCESS_KEY: config["UNSPLASH_ACCESS_KEY"],
+ },
+ });
+
+ const onSubmit = async (formData: ImageConfigFormValues) => {
+ const payload: Partial = { ...formData };
+
+ await updateInstanceConfigurations(payload)
+ .then(() =>
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: "Success",
+ message: "Image Configuration Settings updated successfully",
+ })
+ )
+ .catch((err) => console.error(err));
+ };
+
+ return (
+
+
+
+ You will find your access key in your Unsplash developer console.
+
+ Learn more.
+
+ >
+ }
+ placeholder="oXgq-sdfadsaeweqasdfasdf3234234rassd"
+ error={Boolean(errors.UNSPLASH_ACCESS_KEY)}
+ required
+ />
+
+
+
+
+ {isSubmitting ? "Saving..." : "Save changes"}
+
+
+
+ );
+};
diff --git a/admin/app/image/layout.tsx b/admin/app/image/layout.tsx
new file mode 100644
index 000000000..18e9343b5
--- /dev/null
+++ b/admin/app/image/layout.tsx
@@ -0,0 +1,15 @@
+import { ReactNode } from "react";
+import { Metadata } from "next";
+import { AdminLayout } from "@/layouts/admin-layout";
+
+interface ImageLayoutProps {
+ children: ReactNode;
+}
+
+export const metadata: Metadata = {
+ title: "Images Settings - God Mode",
+};
+
+const ImageLayout = ({ children }: ImageLayoutProps) => {children} ;
+
+export default ImageLayout;
diff --git a/admin/app/image/page.tsx b/admin/app/image/page.tsx
new file mode 100644
index 000000000..5c1b838be
--- /dev/null
+++ b/admin/app/image/page.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import { observer } from "mobx-react-lite";
+import useSWR from "swr";
+import { Loader } from "@plane/ui";
+// components
+import { PageHeader } from "@/components/core";
+// hooks
+import { useInstance } from "@/hooks/store";
+// local
+import { InstanceImageConfigForm } from "./form";
+
+const InstanceImagePage = observer(() => {
+ // store
+ const { formattedConfig, fetchInstanceConfigurations } = useInstance();
+
+ useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
+
+ return (
+ <>
+
+
+
+
Third-party image libraries
+
+ Let your users search and choose images from third-party libraries
+
+
+
+ {formattedConfig ? (
+
+ ) : (
+
+
+
+
+ )}
+
+
+ >
+ );
+});
+
+export default InstanceImagePage;
diff --git a/admin/app/layout.tsx b/admin/app/layout.tsx
new file mode 100644
index 000000000..e79d0bac8
--- /dev/null
+++ b/admin/app/layout.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import { ReactNode } from "react";
+import { ThemeProvider, useTheme } from "next-themes";
+import { SWRConfig } from "swr";
+// ui
+import { Toast } from "@plane/ui";
+// constants
+import { SWR_CONFIG } from "@/constants/swr-config";
+// helpers
+import { ASSET_PREFIX, resolveGeneralTheme } from "@/helpers/common.helper";
+// lib
+import { InstanceProvider } from "@/lib/instance-provider";
+import { StoreProvider } from "@/lib/store-provider";
+import { UserProvider } from "@/lib/user-provider";
+// styles
+import "./globals.css";
+
+function RootLayout({ children }: { children: ReactNode }) {
+ // themes
+ const { resolvedTheme } = useTheme();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+ );
+}
+
+export default RootLayout;
diff --git a/admin/app/page.tsx b/admin/app/page.tsx
new file mode 100644
index 000000000..b402fc44d
--- /dev/null
+++ b/admin/app/page.tsx
@@ -0,0 +1,30 @@
+import { Metadata } from "next";
+// components
+import { InstanceSignInForm } from "@/components/login";
+// layouts
+import { DefaultLayout } from "@/layouts/default-layout";
+
+export const metadata: Metadata = {
+ title: "Plane | Simple, extensible, open-source project management tool.",
+ description:
+ "Open-source project management tool to manage issues, sprints, and product roadmaps with peace of mind.",
+ openGraph: {
+ title: "Plane | Simple, extensible, open-source project management tool.",
+ description:
+ "Open-source project management tool to manage issues, sprints, and product roadmaps with peace of mind.",
+ url: "https://plane.so/",
+ },
+ keywords:
+ "software development, customer feedback, software, accelerate, code management, release management, project management, issue tracking, agile, scrum, kanban, collaboration",
+ twitter: {
+ site: "@planepowers",
+ },
+};
+
+export default async function LoginPage() {
+ return (
+
+
+
+ );
+}
diff --git a/web/components/instance/help-section.tsx b/admin/components/admin-sidebar/help-section.tsx
similarity index 54%
rename from web/components/instance/help-section.tsx
rename to admin/components/admin-sidebar/help-section.tsx
index 5f6603fe7..371bb49d8 100644
--- a/web/components/instance/help-section.tsx
+++ b/admin/components/admin-sidebar/help-section.tsx
@@ -1,11 +1,14 @@
+"use client";
+
import { FC, useState, useRef } from "react";
+import { observer } from "mobx-react-lite";
import Link from "next/link";
-import { FileText, HelpCircle, MessagesSquare, MoveLeft } from "lucide-react";
+import { ExternalLink, FileText, HelpCircle, MoveLeft } from "lucide-react";
import { Transition } from "@headlessui/react";
+import { DiscordIcon, GithubIcon, Tooltip } from "@plane/ui";
// hooks
-import { DiscordIcon, GithubIcon } from "@plane/ui";
-import { useApplication } from "@/hooks/store";
-// icons
+import { WEB_BASE_URL } from "@/helpers/common.helper";
+import { useTheme } from "@/hooks/store";
// assets
import packageJson from "package.json";
@@ -25,56 +28,56 @@ const helpOptions = [
href: "https://github.com/makeplane/plane/issues/new/choose",
Icon: GithubIcon,
},
- {
- name: "Chat with us",
- href: null,
- onClick: () => (window as any).$crisp.push(["do", "chat:show"]),
- Icon: MessagesSquare,
- },
];
-export const InstanceHelpSection: FC = () => {
+export const HelpSection: FC = observer(() => {
// states
const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false);
// store
- const {
- theme: { sidebarCollapsed, toggleSidebar },
- } = useApplication();
+ const { isSidebarCollapsed, toggleSidebar } = useTheme();
// refs
const helpOptionsRef = useRef(null);
+ const redirectionLink = encodeURI(WEB_BASE_URL + "/create-workspace");
+
return (
-
-
setIsNeedHelpOpen((prev) => !prev)}
- >
-
-
-
toggleSidebar()}
- >
-
-
-
toggleSidebar()}
- >
-
-
+
@@ -89,12 +92,12 @@ export const InstanceHelpSection: FC = () => {
>
- {helpOptions.map(({ name, Icon, href, onClick }) => {
+ {helpOptions.map(({ name, Icon, href }) => {
if (href)
return (
@@ -111,7 +114,6 @@ export const InstanceHelpSection: FC = () => {
@@ -128,4 +130,4 @@ export const InstanceHelpSection: FC = () => {
);
-};
+});
diff --git a/admin/components/admin-sidebar/index.ts b/admin/components/admin-sidebar/index.ts
new file mode 100644
index 000000000..e800fe3c5
--- /dev/null
+++ b/admin/components/admin-sidebar/index.ts
@@ -0,0 +1,5 @@
+export * from "./root";
+export * from "./help-section";
+export * from "./sidebar-menu";
+export * from "./sidebar-dropdown";
+export * from "./sidebar-menu-hamburger-toogle";
diff --git a/admin/components/admin-sidebar/root.tsx b/admin/components/admin-sidebar/root.tsx
new file mode 100644
index 000000000..ff94bf228
--- /dev/null
+++ b/admin/components/admin-sidebar/root.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import { FC, useEffect, useRef } from "react";
+import { observer } from "mobx-react-lite";
+// hooks
+import { HelpSection, SidebarMenu, SidebarDropdown } from "@/components/admin-sidebar";
+import { useTheme } from "@/hooks/store";
+import useOutsideClickDetector from "hooks/use-outside-click-detector";
+// components
+
+export interface IInstanceSidebar {}
+
+export const InstanceSidebar: FC
= observer(() => {
+ // store
+ const { isSidebarCollapsed, toggleSidebar } = useTheme();
+
+ const ref = useRef(null);
+
+ useOutsideClickDetector(ref, () => {
+ if (isSidebarCollapsed === false) {
+ if (window.innerWidth < 768) {
+ toggleSidebar(!isSidebarCollapsed);
+ }
+ }
+ });
+
+ useEffect(() => {
+ const handleResize = () => {
+ if (window.innerWidth <= 768) {
+ toggleSidebar(true);
+ }
+ };
+ handleResize();
+ window.addEventListener("resize", handleResize);
+ return () => {
+ window.removeEventListener("resize", handleResize);
+ };
+ }, [toggleSidebar]);
+
+ return (
+
+ );
+});
diff --git a/admin/components/admin-sidebar/sidebar-dropdown.tsx b/admin/components/admin-sidebar/sidebar-dropdown.tsx
new file mode 100644
index 000000000..84583e24b
--- /dev/null
+++ b/admin/components/admin-sidebar/sidebar-dropdown.tsx
@@ -0,0 +1,147 @@
+"use client";
+
+import { Fragment, useEffect, useState } from "react";
+import { observer } from "mobx-react-lite";
+import { useTheme as useNextTheme } from "next-themes";
+import { LogOut, UserCog2, Palette } from "lucide-react";
+import { Menu, Transition } from "@headlessui/react";
+import { Avatar } from "@plane/ui";
+// hooks
+import { API_BASE_URL, cn } from "@/helpers/common.helper";
+import { useTheme, useUser } from "@/hooks/store";
+// helpers
+// services
+import { AuthService } from "@/services/auth.service";
+
+// service initialization
+const authService = new AuthService();
+
+export const SidebarDropdown = observer(() => {
+ // store hooks
+ const { isSidebarCollapsed } = useTheme();
+ const { currentUser, signOut } = useUser();
+ // hooks
+ const { resolvedTheme, setTheme } = useNextTheme();
+ // state
+ const [csrfToken, setCsrfToken] = useState(undefined);
+
+ const handleThemeSwitch = () => {
+ const newTheme = resolvedTheme === "dark" ? "light" : "dark";
+ setTheme(newTheme);
+ };
+
+ const handleSignOut = () => signOut();
+
+ const getSidebarMenuItems = () => (
+
+
+ {currentUser?.email}
+
+
+
+
+ Switch to {resolvedTheme === "dark" ? "light" : "dark"} mode
+
+
+
+
+
+
+ );
+
+ useEffect(() => {
+ if (csrfToken === undefined)
+ authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
+ }, [csrfToken]);
+
+ return (
+
+
+
+
+
+
+
+
+
+ {isSidebarCollapsed && (
+
+ {getSidebarMenuItems()}
+
+ )}
+
+
+ {!isSidebarCollapsed && (
+
+
Instance admin
+
+ )}
+
+
+
+ {!isSidebarCollapsed && currentUser && (
+
+
+
+
+
+
+ {getSidebarMenuItems()}
+
+
+ )}
+
+ );
+});
diff --git a/admin/components/admin-sidebar/sidebar-menu-hamburger-toogle.tsx b/admin/components/admin-sidebar/sidebar-menu-hamburger-toogle.tsx
new file mode 100644
index 000000000..2e8539488
--- /dev/null
+++ b/admin/components/admin-sidebar/sidebar-menu-hamburger-toogle.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+import { FC } from "react";
+import { observer } from "mobx-react-lite";
+// hooks
+import { Menu } from "lucide-react";
+import { useTheme } from "@/hooks/store";
+// icons
+
+export const SidebarHamburgerToggle: FC = observer(() => {
+ const { isSidebarCollapsed, toggleSidebar } = useTheme();
+ return (
+ toggleSidebar(!isSidebarCollapsed)}
+ >
+
+
+ );
+});
diff --git a/admin/components/admin-sidebar/sidebar-menu.tsx b/admin/components/admin-sidebar/sidebar-menu.tsx
new file mode 100644
index 000000000..f7c146fa2
--- /dev/null
+++ b/admin/components/admin-sidebar/sidebar-menu.tsx
@@ -0,0 +1,104 @@
+"use client";
+
+import { observer } from "mobx-react-lite";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react";
+import { Tooltip } from "@plane/ui";
+// hooks
+import { cn } from "@/helpers/common.helper";
+import { useTheme } from "@/hooks/store";
+// helpers
+
+const INSTANCE_ADMIN_LINKS = [
+ {
+ Icon: Cog,
+ name: "General",
+ description: "Identify your instances and get key details",
+ href: `/general/`,
+ },
+ {
+ Icon: Mail,
+ name: "Email",
+ description: "Set up emails to your users",
+ href: `/email/`,
+ },
+ {
+ Icon: Lock,
+ name: "Authentication",
+ description: "Configure authentication modes",
+ href: `/authentication/`,
+ },
+ {
+ Icon: BrainCog,
+ name: "Artificial intelligence",
+ description: "Configure your OpenAI creds",
+ href: `/ai/`,
+ },
+ {
+ Icon: Image,
+ name: "Images in Plane",
+ description: "Allow third-party image libraries",
+ href: `/image/`,
+ },
+];
+
+export const SidebarMenu = observer(() => {
+ // store hooks
+ const { isSidebarCollapsed, toggleSidebar } = useTheme();
+ // router
+ const pathName = usePathname();
+
+ const handleItemClick = () => {
+ if (window.innerWidth < 768) {
+ toggleSidebar(!isSidebarCollapsed);
+ }
+ };
+
+ return (
+
+ {INSTANCE_ADMIN_LINKS.map((item, index) => {
+ const isActive = item.href === pathName || pathName.includes(item.href);
+ return (
+
+
+
+
+ {
}
+ {!isSidebarCollapsed && (
+
+
+ {item.name}
+
+
+ {item.description}
+
+
+ )}
+
+
+
+
+ );
+ })}
+
+ );
+});
diff --git a/admin/components/auth-header.tsx b/admin/components/auth-header.tsx
new file mode 100644
index 000000000..4becf928f
--- /dev/null
+++ b/admin/components/auth-header.tsx
@@ -0,0 +1,90 @@
+"use client";
+
+import { FC } from "react";
+import { observer } from "mobx-react-lite";
+import { usePathname } from "next/navigation";
+// mobx
+// ui
+import { Settings } from "lucide-react";
+// icons
+import { Breadcrumbs } from "@plane/ui";
+// components
+import { SidebarHamburgerToggle } from "@/components/admin-sidebar";
+import { BreadcrumbLink } from "components/common";
+
+export const InstanceHeader: FC = observer(() => {
+ const pathName = usePathname();
+
+ const getHeaderTitle = (pathName: string) => {
+ switch (pathName) {
+ case "general":
+ return "General";
+ case "ai":
+ return "Artificial Intelligence";
+ case "email":
+ return "Email";
+ case "authentication":
+ return "Authentication";
+ case "image":
+ return "Image";
+ case "google":
+ return "Google";
+ case "github":
+ return "Github";
+ default:
+ return pathName.toUpperCase();
+ }
+ };
+
+ // Function to dynamically generate breadcrumb items based on pathname
+ const generateBreadcrumbItems = (pathname: string) => {
+ const pathSegments = pathname.split("/").slice(1); // removing the first empty string.
+ pathSegments.pop();
+
+ let currentUrl = "";
+ const breadcrumbItems = pathSegments.map((segment) => {
+ currentUrl += "/" + segment;
+ return {
+ title: getHeaderTitle(segment),
+ href: currentUrl,
+ };
+ });
+ return breadcrumbItems;
+ };
+
+ const breadcrumbItems = generateBreadcrumbItems(pathName);
+
+ return (
+
+
+
+ {breadcrumbItems.length >= 0 && (
+
+
+ }
+ />
+ }
+ />
+ {breadcrumbItems.map(
+ (item) =>
+ item.title && (
+ }
+ />
+ )
+ )}
+
+
+ )}
+
+
+ );
+});
diff --git a/admin/components/common/banner.tsx b/admin/components/common/banner.tsx
new file mode 100644
index 000000000..932a0c629
--- /dev/null
+++ b/admin/components/common/banner.tsx
@@ -0,0 +1,32 @@
+import { FC } from "react";
+import { AlertCircle, CheckCircle2 } from "lucide-react";
+
+type TBanner = {
+ type: "success" | "error";
+ message: string;
+};
+
+export const Banner: FC = (props) => {
+ const { type, message } = props;
+
+ return (
+
+
+
+ {type === "error" ? (
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+};
diff --git a/admin/components/common/breadcrumb-link.tsx b/admin/components/common/breadcrumb-link.tsx
new file mode 100644
index 000000000..dfa437231
--- /dev/null
+++ b/admin/components/common/breadcrumb-link.tsx
@@ -0,0 +1,36 @@
+import Link from "next/link";
+import { Tooltip } from "@plane/ui";
+
+type Props = {
+ label?: string;
+ href?: string;
+ icon?: React.ReactNode | undefined;
+};
+
+export const BreadcrumbLink: React.FC = (props) => {
+ const { href, label, icon } = props;
+ return (
+
+
+
+ {href ? (
+
+ {icon && (
+
{icon}
+ )}
+
{label}
+
+ ) : (
+
+ {icon &&
{icon}
}
+
{label}
+
+ )}
+
+
+
+ );
+};
diff --git a/admin/components/common/confirm-discard-modal.tsx b/admin/components/common/confirm-discard-modal.tsx
new file mode 100644
index 000000000..64e4d7a08
--- /dev/null
+++ b/admin/components/common/confirm-discard-modal.tsx
@@ -0,0 +1,83 @@
+import React from "react";
+import Link from "next/link";
+// headless ui
+import { Dialog, Transition } from "@headlessui/react";
+// ui
+import { Button, getButtonStyling } from "@plane/ui";
+
+type Props = {
+ isOpen: boolean;
+ handleClose: () => void;
+ onDiscardHref: string;
+};
+
+export const ConfirmDiscardModal: React.FC = (props) => {
+ const { isOpen, handleClose, onDiscardHref } = props;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ You have unsaved changes
+
+
+
+ Changes you made will be lost if you go back. Do you
+ wish to go back?
+
+
+
+
+
+
+
+ Keep editing
+
+
+ Go back
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/admin/components/common/controller-input.tsx b/admin/components/common/controller-input.tsx
new file mode 100644
index 000000000..0eb215095
--- /dev/null
+++ b/admin/components/common/controller-input.tsx
@@ -0,0 +1,86 @@
+"use client";
+
+import React, { useState } from "react";
+import { Controller, Control } from "react-hook-form";
+// icons
+import { Eye, EyeOff } from "lucide-react";
+// ui
+import { Input } from "@plane/ui";
+// helpers
+import { cn } from "@/helpers/common.helper";
+
+type Props = {
+ control: Control;
+ type: "text" | "password";
+ name: string;
+ label: string;
+ description?: string | JSX.Element;
+ placeholder: string;
+ error: boolean;
+ required: boolean;
+};
+
+export type TControllerInputFormField = {
+ key: string;
+ type: "text" | "password";
+ label: string;
+ description?: string | JSX.Element;
+ placeholder: string;
+ error: boolean;
+ required: boolean;
+};
+
+export const ControllerInput: React.FC = (props) => {
+ const { name, control, type, label, description, placeholder, error, required } = props;
+ // states
+ const [showPassword, setShowPassword] = useState(false);
+
+ return (
+
+
+ {label} {!required && "(optional)"}
+
+
+ (
+
+ )}
+ />
+ {type === "password" &&
+ (showPassword ? (
+ setShowPassword(false)}
+ >
+
+
+ ) : (
+ setShowPassword(true)}
+ >
+
+
+ ))}
+
+ {description &&
{description}
}
+
+ );
+};
diff --git a/admin/components/common/copy-field.tsx b/admin/components/common/copy-field.tsx
new file mode 100644
index 000000000..47e1a3364
--- /dev/null
+++ b/admin/components/common/copy-field.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import React from "react";
+// ui
+import { Copy } from "lucide-react";
+import { Button, TOAST_TYPE, setToast } from "@plane/ui";
+// icons
+
+type Props = {
+ label: string;
+ url: string;
+ description: string | JSX.Element;
+};
+
+export type TCopyField = {
+ key: string;
+ label: string;
+ url: string;
+ description: string | JSX.Element;
+};
+
+export const CopyField: React.FC = (props) => {
+ const { label, url, description } = props;
+
+ return (
+
+
{label}
+
{
+ navigator.clipboard.writeText(url);
+ setToast({
+ type: TOAST_TYPE.INFO,
+ title: "Copied to clipboard",
+ message: `The ${label} has been successfully copied to your clipboard`,
+ });
+ }}
+ >
+ {url}
+
+
+
{description}
+
+ );
+};
diff --git a/admin/components/common/empty-state.tsx b/admin/components/common/empty-state.tsx
new file mode 100644
index 000000000..fbbe0bc0f
--- /dev/null
+++ b/admin/components/common/empty-state.tsx
@@ -0,0 +1,46 @@
+import React from "react";
+import Image from "next/image";
+import { Button } from "@plane/ui";
+
+type Props = {
+ title: string;
+ description?: React.ReactNode;
+ image?: any;
+ primaryButton?: {
+ icon?: any;
+ text: string;
+ onClick: () => void;
+ };
+ secondaryButton?: React.ReactNode;
+ disabled?: boolean;
+};
+
+export const EmptyState: React.FC = ({
+ title,
+ description,
+ image,
+ primaryButton,
+ secondaryButton,
+ disabled = false,
+}) => (
+
+
+ {image &&
}
+
{title}
+ {description &&
{description}
}
+
+ {primaryButton && (
+
+ {primaryButton.text}
+
+ )}
+ {secondaryButton}
+
+
+
+);
diff --git a/admin/components/common/index.ts b/admin/components/common/index.ts
new file mode 100644
index 000000000..c810cac69
--- /dev/null
+++ b/admin/components/common/index.ts
@@ -0,0 +1,9 @@
+export * from "./breadcrumb-link";
+export * from "./confirm-discard-modal";
+export * from "./controller-input";
+export * from "./copy-field";
+export * from "./password-strength-meter";
+export * from "./banner";
+export * from "./empty-state";
+export * from "./logo-spinner";
+export * from "./toast";
diff --git a/admin/components/common/logo-spinner.tsx b/admin/components/common/logo-spinner.tsx
new file mode 100644
index 000000000..621b685b8
--- /dev/null
+++ b/admin/components/common/logo-spinner.tsx
@@ -0,0 +1,17 @@
+import Image from "next/image";
+import { useTheme } from "next-themes";
+// assets
+import LogoSpinnerDark from "@/public/images/logo-spinner-dark.gif";
+import LogoSpinnerLight from "@/public/images/logo-spinner-light.gif";
+
+export const LogoSpinner = () => {
+ const { resolvedTheme } = useTheme();
+
+ const logoSrc = resolvedTheme === "dark" ? LogoSpinnerDark : LogoSpinnerLight;
+
+ return (
+
+
+
+ );
+};
diff --git a/admin/components/common/password-strength-meter.tsx b/admin/components/common/password-strength-meter.tsx
new file mode 100644
index 000000000..004a927b2
--- /dev/null
+++ b/admin/components/common/password-strength-meter.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+// helpers
+import { CircleCheck } from "lucide-react";
+import { cn } from "@/helpers/common.helper";
+import { getPasswordStrength } from "@/helpers/password.helper";
+// icons
+
+type Props = {
+ password: string;
+};
+
+export const PasswordStrengthMeter: React.FC = (props: Props) => {
+ const { password } = props;
+
+ const strength = getPasswordStrength(password);
+ let bars = [];
+ let text = "";
+ let textColor = "";
+
+ if (password.length === 0) {
+ bars = [`bg-[#F0F0F3]`, `bg-[#F0F0F3]`, `bg-[#F0F0F3]`];
+ text = "Password requirements";
+ } else if (password.length < 8) {
+ bars = [`bg-[#DC3E42]`, `bg-[#F0F0F3]`, `bg-[#F0F0F3]`];
+ text = "Password is too short";
+ textColor = `text-[#DC3E42]`;
+ } else if (strength < 3) {
+ bars = [`bg-[#FFBA18]`, `bg-[#FFBA18]`, `bg-[#F0F0F3]`];
+ text = "Password is weak";
+ textColor = `text-[#FFBA18]`;
+ } else {
+ bars = [`bg-[#3E9B4F]`, `bg-[#3E9B4F]`, `bg-[#3E9B4F]`];
+ text = "Password is strong";
+ textColor = `text-[#3E9B4F]`;
+ }
+
+ const criteria = [
+ { label: "Min 8 characters", isValid: password.length >= 8 },
+ { label: "Min 1 upper-case letter", isValid: /[A-Z]/.test(password) },
+ { label: "Min 1 number", isValid: /\d/.test(password) },
+ { label: "Min 1 special character", isValid: /[!@#$%^&*]/.test(password) },
+ ];
+
+ return (
+
+
+ {bars.map((color, index) => (
+
+ ))}
+
+
{text}
+
+ {criteria.map((criterion, index) => (
+
+
+ {criterion.label}
+
+ ))}
+
+
+ );
+};
diff --git a/admin/components/common/toast.tsx b/admin/components/common/toast.tsx
new file mode 100644
index 000000000..fe4983db6
--- /dev/null
+++ b/admin/components/common/toast.tsx
@@ -0,0 +1,11 @@
+import { useTheme } from "next-themes";
+// ui
+import { Toast as ToastComponent } from "@plane/ui";
+// helpers
+import { resolveGeneralTheme } from "@/helpers/common.helper";
+
+export const Toast = () => {
+ const { theme } = useTheme();
+
+ return ;
+};
diff --git a/admin/components/core/index.ts b/admin/components/core/index.ts
new file mode 100644
index 000000000..d32aafe96
--- /dev/null
+++ b/admin/components/core/index.ts
@@ -0,0 +1 @@
+export * from "./page-header";
diff --git a/admin/components/core/page-header.tsx b/admin/components/core/page-header.tsx
new file mode 100644
index 000000000..a4b27b92f
--- /dev/null
+++ b/admin/components/core/page-header.tsx
@@ -0,0 +1,17 @@
+"use client";
+
+type TPageHeader = {
+ title?: string;
+ description?: string;
+};
+
+export const PageHeader: React.FC = (props) => {
+ const { title = "God Mode - Plane", description = "Plane god mode" } = props;
+
+ return (
+ <>
+ {title}
+
+ >
+ );
+};
diff --git a/admin/components/instance/index.ts b/admin/components/instance/index.ts
new file mode 100644
index 000000000..56d1933f4
--- /dev/null
+++ b/admin/components/instance/index.ts
@@ -0,0 +1,3 @@
+export * from "./instance-not-ready";
+export * from "./instance-failure-view";
+export * from "./setup-form";
diff --git a/admin/components/instance/instance-failure-view.tsx b/admin/components/instance/instance-failure-view.tsx
new file mode 100644
index 000000000..8722929b5
--- /dev/null
+++ b/admin/components/instance/instance-failure-view.tsx
@@ -0,0 +1,42 @@
+"use client";
+import { FC } from "react";
+import Image from "next/image";
+import { useTheme } from "next-themes";
+import { Button } from "@plane/ui";
+// assets
+import InstanceFailureDarkImage from "@/public/instance/instance-failure-dark.svg";
+import InstanceFailureImage from "@/public/instance/instance-failure.svg";
+
+type InstanceFailureViewProps = {
+ // mutate: () => void;
+};
+
+export const InstanceFailureView: FC = () => {
+ const { resolvedTheme } = useTheme();
+
+ const instanceImage = resolvedTheme === "dark" ? InstanceFailureDarkImage : InstanceFailureImage;
+
+ const handleRetry = () => {
+ window.location.reload();
+ };
+
+ return (
+
+
+
+
+
Unable to fetch instance details.
+
+ We were unable to fetch the details of the instance.
+ Fret not, it might just be a connectivity issue.
+
+
+
+
+ Retry
+
+
+
+
+ );
+};
diff --git a/admin/components/instance/instance-not-ready.tsx b/admin/components/instance/instance-not-ready.tsx
new file mode 100644
index 000000000..874013f52
--- /dev/null
+++ b/admin/components/instance/instance-not-ready.tsx
@@ -0,0 +1,30 @@
+"use client";
+
+import { FC } from "react";
+import Image from "next/image";
+import Link from "next/link";
+import { Button } from "@plane/ui";
+// assets
+import PlaneTakeOffImage from "@/public/images/plane-takeoff.png";
+
+export const InstanceNotReady: FC = () => (
+
+
+
+
Welcome aboard Plane!
+
+
+ Get started by setting up your instance and workspace
+
+
+
+
+
+
+ Get started
+
+
+
+
+
+);
diff --git a/admin/components/instance/setup-form.tsx b/admin/components/instance/setup-form.tsx
new file mode 100644
index 000000000..56d536c74
--- /dev/null
+++ b/admin/components/instance/setup-form.tsx
@@ -0,0 +1,354 @@
+"use client";
+
+import { FC, useEffect, useMemo, useState } from "react";
+import { useSearchParams } from "next/navigation";
+// icons
+import { Eye, EyeOff } from "lucide-react";
+// ui
+import { Button, Checkbox, Input, Spinner } from "@plane/ui";
+// components
+import { Banner, PasswordStrengthMeter } from "@/components/common";
+// helpers
+import { API_BASE_URL } from "@/helpers/common.helper";
+import { getPasswordStrength } from "@/helpers/password.helper";
+// services
+import { AuthService } from "@/services/auth.service";
+
+// service initialization
+const authService = new AuthService();
+
+// error codes
+enum EErrorCodes {
+ INSTANCE_NOT_CONFIGURED = "INSTANCE_NOT_CONFIGURED",
+ ADMIN_ALREADY_EXIST = "ADMIN_ALREADY_EXIST",
+ REQUIRED_EMAIL_PASSWORD_FIRST_NAME = "REQUIRED_EMAIL_PASSWORD_FIRST_NAME",
+ INVALID_EMAIL = "INVALID_EMAIL",
+ INVALID_PASSWORD = "INVALID_PASSWORD",
+ USER_ALREADY_EXISTS = "USER_ALREADY_EXISTS",
+}
+
+type TError = {
+ type: EErrorCodes | undefined;
+ message: string | undefined;
+};
+
+// form data
+type TFormData = {
+ first_name: string;
+ last_name: string;
+ email: string;
+ company_name: string;
+ password: string;
+ confirm_password?: string;
+ is_telemetry_enabled: boolean;
+};
+
+const defaultFromData: TFormData = {
+ first_name: "",
+ last_name: "",
+ email: "",
+ company_name: "",
+ password: "",
+ is_telemetry_enabled: true,
+};
+
+export const InstanceSetupForm: FC = (props) => {
+ const {} = props;
+ // search params
+ const searchParams = useSearchParams();
+ const firstNameParam = searchParams.get("first_name") || undefined;
+ const lastNameParam = searchParams.get("last_name") || undefined;
+ const companyParam = searchParams.get("company") || undefined;
+ const emailParam = searchParams.get("email") || undefined;
+ const isTelemetryEnabledParam = (searchParams.get("is_telemetry_enabled") === "True" ? true : false) || true;
+ const errorCode = searchParams.get("error_code") || undefined;
+ const errorMessage = searchParams.get("error_message") || undefined;
+ // state
+ const [showPassword, setShowPassword] = useState({
+ password: false,
+ retypePassword: false,
+ });
+ const [csrfToken, setCsrfToken] = useState(undefined);
+ const [formData, setFormData] = useState(defaultFromData);
+ const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false);
+
+ const handleShowPassword = (key: keyof typeof showPassword) =>
+ setShowPassword((prev) => ({ ...prev, [key]: !prev[key] }));
+
+ const handleFormChange = (key: keyof TFormData, value: string | boolean) =>
+ setFormData((prev) => ({ ...prev, [key]: value }));
+
+ useEffect(() => {
+ if (csrfToken === undefined)
+ authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
+ }, [csrfToken]);
+
+ useEffect(() => {
+ if (firstNameParam) setFormData((prev) => ({ ...prev, first_name: firstNameParam }));
+ if (lastNameParam) setFormData((prev) => ({ ...prev, last_name: lastNameParam }));
+ if (companyParam) setFormData((prev) => ({ ...prev, company_name: companyParam }));
+ if (emailParam) setFormData((prev) => ({ ...prev, email: emailParam }));
+ if (isTelemetryEnabledParam) setFormData((prev) => ({ ...prev, is_telemetry_enabled: isTelemetryEnabledParam }));
+ }, [firstNameParam, lastNameParam, companyParam, emailParam, isTelemetryEnabledParam]);
+
+ // derived values
+ const errorData: TError = useMemo(() => {
+ if (errorCode && errorMessage) {
+ switch (errorCode) {
+ case EErrorCodes.INSTANCE_NOT_CONFIGURED:
+ return { type: EErrorCodes.INSTANCE_NOT_CONFIGURED, message: errorMessage };
+ case EErrorCodes.ADMIN_ALREADY_EXIST:
+ return { type: EErrorCodes.ADMIN_ALREADY_EXIST, message: errorMessage };
+ case EErrorCodes.REQUIRED_EMAIL_PASSWORD_FIRST_NAME:
+ return { type: EErrorCodes.REQUIRED_EMAIL_PASSWORD_FIRST_NAME, message: errorMessage };
+ case EErrorCodes.INVALID_EMAIL:
+ return { type: EErrorCodes.INVALID_EMAIL, message: errorMessage };
+ case EErrorCodes.INVALID_PASSWORD:
+ return { type: EErrorCodes.INVALID_PASSWORD, message: errorMessage };
+ case EErrorCodes.USER_ALREADY_EXISTS:
+ return { type: EErrorCodes.USER_ALREADY_EXISTS, message: errorMessage };
+ default:
+ return { type: undefined, message: undefined };
+ }
+ } else return { type: undefined, message: undefined };
+ }, [errorCode, errorMessage]);
+
+ const isButtonDisabled = useMemo(
+ () =>
+ !isSubmitting &&
+ formData.first_name &&
+ formData.email &&
+ formData.password &&
+ getPasswordStrength(formData.password) >= 3 &&
+ formData.password === formData.confirm_password
+ ? false
+ : true,
+ [formData.confirm_password, formData.email, formData.first_name, formData.password, isSubmitting]
+ );
+
+ const password = formData?.password ?? "";
+ const confirmPassword = formData?.confirm_password ?? "";
+ const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length;
+
+ return (
+
+
+
+
+ Setup your Plane Instance
+
+
+ Post setup you will be able to manage this Plane instance.
+
+
+
+ {errorData.type &&
+ errorData?.message &&
+ ![EErrorCodes.INVALID_EMAIL, EErrorCodes.INVALID_PASSWORD].includes(errorData.type) && (
+
+ )}
+
+
+
+
+ );
+};
diff --git a/web/components/instance/setup-form/index.ts b/admin/components/login/index.ts
similarity index 57%
rename from web/components/instance/setup-form/index.ts
rename to admin/components/login/index.ts
index e9a965d6d..bdeb387f3 100644
--- a/web/components/instance/setup-form/index.ts
+++ b/admin/components/login/index.ts
@@ -1,2 +1 @@
-export * from "./root";
export * from "./sign-in-form";
diff --git a/admin/components/login/sign-in-form.tsx b/admin/components/login/sign-in-form.tsx
new file mode 100644
index 000000000..45d448d12
--- /dev/null
+++ b/admin/components/login/sign-in-form.tsx
@@ -0,0 +1,179 @@
+"use client";
+
+import { FC, useEffect, useMemo, useState } from "react";
+import { useSearchParams } from "next/navigation";
+// services
+import { Eye, EyeOff } from "lucide-react";
+import { Button, Input, Spinner } from "@plane/ui";
+// components
+import { Banner } from "@/components/common";
+// helpers
+import { API_BASE_URL } from "@/helpers/common.helper";
+import { AuthService } from "@/services/auth.service";
+// ui
+// icons
+
+// service initialization
+const authService = new AuthService();
+
+// error codes
+enum EErrorCodes {
+ INSTANCE_NOT_CONFIGURED = "INSTANCE_NOT_CONFIGURED",
+ REQUIRED_EMAIL_PASSWORD = "REQUIRED_EMAIL_PASSWORD",
+ INVALID_EMAIL = "INVALID_EMAIL",
+ USER_DOES_NOT_EXIST = "USER_DOES_NOT_EXIST",
+ AUTHENTICATION_FAILED = "AUTHENTICATION_FAILED",
+}
+
+type TError = {
+ type: EErrorCodes | undefined;
+ message: string | undefined;
+};
+
+// form data
+type TFormData = {
+ email: string;
+ password: string;
+};
+
+const defaultFromData: TFormData = {
+ email: "",
+ password: "",
+};
+
+export const InstanceSignInForm: FC = (props) => {
+ const {} = props;
+ // search params
+ const searchParams = useSearchParams();
+ const emailParam = searchParams.get("email") || undefined;
+ const errorCode = searchParams.get("error_code") || undefined;
+ const errorMessage = searchParams.get("error_message") || undefined;
+ // state
+ const [showPassword, setShowPassword] = useState(false);
+ const [csrfToken, setCsrfToken] = useState(undefined);
+ const [formData, setFormData] = useState(defaultFromData);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const handleFormChange = (key: keyof TFormData, value: string | boolean) =>
+ setFormData((prev) => ({ ...prev, [key]: value }));
+
+ console.log("csrfToken", csrfToken);
+
+ useEffect(() => {
+ if (csrfToken === undefined)
+ authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
+ }, [csrfToken]);
+
+ useEffect(() => {
+ if (emailParam) setFormData((prev) => ({ ...prev, email: emailParam }));
+ }, [emailParam]);
+
+ // derived values
+ const errorData: TError = useMemo(() => {
+ if (errorCode && errorMessage) {
+ switch (errorCode) {
+ case EErrorCodes.INSTANCE_NOT_CONFIGURED:
+ return { type: EErrorCodes.INVALID_EMAIL, message: errorMessage };
+ case EErrorCodes.REQUIRED_EMAIL_PASSWORD:
+ return { type: EErrorCodes.REQUIRED_EMAIL_PASSWORD, message: errorMessage };
+ case EErrorCodes.INVALID_EMAIL:
+ return { type: EErrorCodes.INVALID_EMAIL, message: errorMessage };
+ case EErrorCodes.USER_DOES_NOT_EXIST:
+ return { type: EErrorCodes.USER_DOES_NOT_EXIST, message: errorMessage };
+ case EErrorCodes.AUTHENTICATION_FAILED:
+ return { type: EErrorCodes.AUTHENTICATION_FAILED, message: errorMessage };
+ default:
+ return { type: undefined, message: undefined };
+ }
+ } else return { type: undefined, message: undefined };
+ }, [errorCode, errorMessage]);
+
+ const isButtonDisabled = useMemo(
+ () => (!isSubmitting && formData.email && formData.password ? false : true),
+ [formData.email, formData.password, isSubmitting]
+ );
+
+ return (
+
+
+
+
+ Manage your Plane instance
+
+
+ Configure instance-wide settings to secure your instance
+
+
+
+ {errorData.type && errorData?.message &&
}
+
+
+
+
+ );
+};
diff --git a/admin/components/new-user-popup.tsx b/admin/components/new-user-popup.tsx
new file mode 100644
index 000000000..73a405d4a
--- /dev/null
+++ b/admin/components/new-user-popup.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import React from "react";
+import { observer } from "mobx-react-lite";
+import Image from "next/image";
+import { useTheme as nextUseTheme } from "next-themes";
+// ui
+import { Button, getButtonStyling } from "@plane/ui";
+// helpers
+import { resolveGeneralTheme } from "helpers/common.helper";
+// hooks
+import { useInstance, useTheme } from "@/hooks/store";
+// icons
+import TakeoffIconLight from "/public/logos/takeoff-icon-light.svg";
+import TakeoffIconDark from "/public/logos/takeoff-icon-dark.svg";
+
+export const NewUserPopup: React.FC = observer(() => {
+ // hooks
+ const { isNewUserPopup, toggleNewUserPopup } = useTheme();
+ const { config } = useInstance();
+ // theme
+ const { resolvedTheme } = nextUseTheme();
+
+ const redirectionLink = `${config?.app_base_url ? `${config?.app_base_url}/create-workspace` : `/god-mode/`}`;
+
+ if (!isNewUserPopup) return <>>;
+ return (
+
+
+
+
Create workspace
+
+ Instance setup done! Welcome to Plane instance portal. Start your journey with by creating your first
+ workspace, you will need to login again.
+
+
+
+
+
+
+
+
+ );
+});
diff --git a/admin/constants/seo.ts b/admin/constants/seo.ts
new file mode 100644
index 000000000..aafd5f7a3
--- /dev/null
+++ b/admin/constants/seo.ts
@@ -0,0 +1,8 @@
+export const SITE_NAME = "Plane | Simple, extensible, open-source project management tool.";
+export const SITE_TITLE = "Plane | Simple, extensible, open-source project management tool.";
+export const SITE_DESCRIPTION =
+ "Open-source project management tool to manage issues, sprints, and product roadmaps with peace of mind.";
+export const SITE_KEYWORDS =
+ "software development, plan, ship, software, accelerate, code management, release management, project management, issue tracking, agile, scrum, kanban, collaboration";
+export const SITE_URL = "https://app.plane.so/";
+export const TWITTER_USER_NAME = "Plane | Simple, extensible, open-source project management tool.";
diff --git a/admin/constants/swr-config.ts b/admin/constants/swr-config.ts
new file mode 100644
index 000000000..38478fcea
--- /dev/null
+++ b/admin/constants/swr-config.ts
@@ -0,0 +1,8 @@
+export const SWR_CONFIG = {
+ refreshWhenHidden: false,
+ revalidateIfStale: false,
+ revalidateOnFocus: false,
+ revalidateOnMount: true,
+ refreshInterval: 600000,
+ errorRetryCount: 3,
+};
diff --git a/admin/helpers/authentication.helper.tsx b/admin/helpers/authentication.helper.tsx
new file mode 100644
index 000000000..cc9058611
--- /dev/null
+++ b/admin/helpers/authentication.helper.tsx
@@ -0,0 +1,136 @@
+import { ReactNode } from "react";
+import Link from "next/link";
+// helpers
+import { SUPPORT_EMAIL } from "./common.helper";
+
+export enum EPageTypes {
+ PUBLIC = "PUBLIC",
+ NON_AUTHENTICATED = "NON_AUTHENTICATED",
+ SET_PASSWORD = "SET_PASSWORD",
+ ONBOARDING = "ONBOARDING",
+ AUTHENTICATED = "AUTHENTICATED",
+}
+
+export enum EAuthModes {
+ SIGN_IN = "SIGN_IN",
+ SIGN_UP = "SIGN_UP",
+}
+
+export enum EAuthSteps {
+ EMAIL = "EMAIL",
+ PASSWORD = "PASSWORD",
+ UNIQUE_CODE = "UNIQUE_CODE",
+}
+
+export enum EErrorAlertType {
+ BANNER_ALERT = "BANNER_ALERT",
+ INLINE_FIRST_NAME = "INLINE_FIRST_NAME",
+ INLINE_EMAIL = "INLINE_EMAIL",
+ INLINE_PASSWORD = "INLINE_PASSWORD",
+ INLINE_EMAIL_CODE = "INLINE_EMAIL_CODE",
+}
+
+export enum EAuthenticationErrorCodes {
+ // Admin
+ ADMIN_ALREADY_EXIST = "5150",
+ REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME = "5155",
+ INVALID_ADMIN_EMAIL = "5160",
+ INVALID_ADMIN_PASSWORD = "5165",
+ REQUIRED_ADMIN_EMAIL_PASSWORD = "5170",
+ ADMIN_AUTHENTICATION_FAILED = "5175",
+ ADMIN_USER_ALREADY_EXIST = "5180",
+ ADMIN_USER_DOES_NOT_EXIST = "5185",
+ ADMIN_USER_DEACTIVATED = "5190",
+}
+
+export type TAuthErrorInfo = {
+ type: EErrorAlertType;
+ code: EAuthenticationErrorCodes;
+ title: string;
+ message: ReactNode;
+};
+
+const errorCodeMessages: {
+ [key in EAuthenticationErrorCodes]: { title: string; message: (email?: string | undefined) => ReactNode };
+} = {
+ // admin
+ [EAuthenticationErrorCodes.ADMIN_ALREADY_EXIST]: {
+ title: `Admin already exists`,
+ message: () => `Admin already exists. Please try again.`,
+ },
+ [EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME]: {
+ title: `Email, password and first name required`,
+ message: () => `Email, password and first name required. Please try again.`,
+ },
+ [EAuthenticationErrorCodes.INVALID_ADMIN_EMAIL]: {
+ title: `Invalid admin email`,
+ message: () => `Invalid admin email. Please try again.`,
+ },
+ [EAuthenticationErrorCodes.INVALID_ADMIN_PASSWORD]: {
+ title: `Invalid admin password`,
+ message: () => `Invalid admin password. Please try again.`,
+ },
+ [EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD]: {
+ title: `Email and password required`,
+ message: () => `Email and password required. Please try again.`,
+ },
+ [EAuthenticationErrorCodes.ADMIN_AUTHENTICATION_FAILED]: {
+ title: `Authentication failed`,
+ message: () => `Authentication failed. Please try again.`,
+ },
+ [EAuthenticationErrorCodes.ADMIN_USER_ALREADY_EXIST]: {
+ title: `Admin user already exists`,
+ message: () => (
+
+ Admin user already exists.
+
+ Sign In
+
+ now.
+
+ ),
+ },
+ [EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST]: {
+ title: `Admin user does not exist`,
+ message: () => (
+
+ Admin user does not exist.
+
+ Sign In
+
+ now.
+
+ ),
+ },
+ [EAuthenticationErrorCodes.ADMIN_USER_DEACTIVATED]: {
+ title: `User account deactivated`,
+ message: () => `User account deactivated. Please contact ${!!SUPPORT_EMAIL ? SUPPORT_EMAIL : "administrator"}.`,
+ },
+};
+
+export const authErrorHandler = (
+ errorCode: EAuthenticationErrorCodes,
+ email?: string | undefined
+): TAuthErrorInfo | undefined => {
+ const bannerAlertErrorCodes = [
+ EAuthenticationErrorCodes.ADMIN_ALREADY_EXIST,
+ EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME,
+ EAuthenticationErrorCodes.INVALID_ADMIN_EMAIL,
+ EAuthenticationErrorCodes.INVALID_ADMIN_PASSWORD,
+ EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD,
+ EAuthenticationErrorCodes.ADMIN_AUTHENTICATION_FAILED,
+ EAuthenticationErrorCodes.ADMIN_USER_ALREADY_EXIST,
+ EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST,
+ EAuthenticationErrorCodes.ADMIN_USER_DEACTIVATED,
+ ];
+
+ if (bannerAlertErrorCodes.includes(errorCode))
+ return {
+ type: EErrorAlertType.BANNER_ALERT,
+ code: errorCode,
+ title: errorCodeMessages[errorCode]?.title || "Error",
+ message: errorCodeMessages[errorCode]?.message(email) || "Something went wrong. Please try again.",
+ };
+
+ return undefined;
+};
diff --git a/admin/helpers/common.helper.ts b/admin/helpers/common.helper.ts
new file mode 100644
index 000000000..e282e5792
--- /dev/null
+++ b/admin/helpers/common.helper.ts
@@ -0,0 +1,20 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "";
+
+export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "";
+
+export const SPACE_BASE_URL = process.env.NEXT_PUBLIC_SPACE_BASE_URL || "";
+export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || "";
+
+export const WEB_BASE_URL = process.env.NEXT_PUBLIC_WEB_BASE_URL || "";
+
+export const SUPPORT_EMAIL = process.env.NEXT_PUBLIC_SUPPORT_EMAIL || "";
+
+export const ASSET_PREFIX = ADMIN_BASE_PATH;
+
+export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
+
+export const resolveGeneralTheme = (resolvedTheme: string | undefined) =>
+ resolvedTheme?.includes("light") ? "light" : resolvedTheme?.includes("dark") ? "dark" : "system";
diff --git a/admin/helpers/index.ts b/admin/helpers/index.ts
new file mode 100644
index 000000000..ae6aab829
--- /dev/null
+++ b/admin/helpers/index.ts
@@ -0,0 +1,2 @@
+export * from "./instance.helper";
+export * from "./user.helper";
diff --git a/admin/helpers/instance.helper.ts b/admin/helpers/instance.helper.ts
new file mode 100644
index 000000000..f929b2211
--- /dev/null
+++ b/admin/helpers/instance.helper.ts
@@ -0,0 +1,9 @@
+export enum EInstanceStatus {
+ ERROR = "ERROR",
+ NOT_YET_READY = "NOT_YET_READY",
+}
+
+export type TInstanceStatus = {
+ status: EInstanceStatus | undefined;
+ data?: object;
+};
diff --git a/admin/helpers/password.helper.ts b/admin/helpers/password.helper.ts
new file mode 100644
index 000000000..8d80b3402
--- /dev/null
+++ b/admin/helpers/password.helper.ts
@@ -0,0 +1,16 @@
+import zxcvbn from "zxcvbn";
+
+export const isPasswordCriteriaMet = (password: string) => {
+ const criteria = [password.length >= 8, /[A-Z]/.test(password), /\d/.test(password), /[!@#$%^&*]/.test(password)];
+
+ return criteria.every((criterion) => criterion);
+};
+
+export const getPasswordStrength = (password: string) => {
+ if (password.length === 0) return 0;
+ if (password.length < 8) return 1;
+ if (!isPasswordCriteriaMet(password)) return 2;
+
+ const result = zxcvbn(password);
+ return result.score;
+};
diff --git a/admin/helpers/user.helper.ts b/admin/helpers/user.helper.ts
new file mode 100644
index 000000000..5c6a89a17
--- /dev/null
+++ b/admin/helpers/user.helper.ts
@@ -0,0 +1,21 @@
+export enum EAuthenticationPageType {
+ STATIC = "STATIC",
+ NOT_AUTHENTICATED = "NOT_AUTHENTICATED",
+ AUTHENTICATED = "AUTHENTICATED",
+}
+
+export enum EInstancePageType {
+ PRE_SETUP = "PRE_SETUP",
+ POST_SETUP = "POST_SETUP",
+}
+
+export enum EUserStatus {
+ ERROR = "ERROR",
+ AUTHENTICATION_NOT_DONE = "AUTHENTICATION_NOT_DONE",
+ NOT_YET_READY = "NOT_YET_READY",
+}
+
+export type TUserStatus = {
+ status: EUserStatus | undefined;
+ message?: string;
+};
diff --git a/admin/hooks/store/index.ts b/admin/hooks/store/index.ts
new file mode 100644
index 000000000..7447064da
--- /dev/null
+++ b/admin/hooks/store/index.ts
@@ -0,0 +1,3 @@
+export * from "./use-theme";
+export * from "./use-instance";
+export * from "./use-user";
diff --git a/admin/hooks/store/use-instance.tsx b/admin/hooks/store/use-instance.tsx
new file mode 100644
index 000000000..cf2edc39f
--- /dev/null
+++ b/admin/hooks/store/use-instance.tsx
@@ -0,0 +1,10 @@
+import { useContext } from "react";
+// store
+import { StoreContext } from "@/lib/store-provider";
+import { IInstanceStore } from "@/store/instance.store";
+
+export const useInstance = (): IInstanceStore => {
+ const context = useContext(StoreContext);
+ if (context === undefined) throw new Error("useInstance must be used within StoreProvider");
+ return context.instance;
+};
diff --git a/admin/hooks/store/use-theme.tsx b/admin/hooks/store/use-theme.tsx
new file mode 100644
index 000000000..bad89cfee
--- /dev/null
+++ b/admin/hooks/store/use-theme.tsx
@@ -0,0 +1,10 @@
+import { useContext } from "react";
+// store
+import { StoreContext } from "@/lib/store-provider";
+import { IThemeStore } from "@/store/theme.store";
+
+export const useTheme = (): IThemeStore => {
+ const context = useContext(StoreContext);
+ if (context === undefined) throw new Error("useTheme must be used within StoreProvider");
+ return context.theme;
+};
diff --git a/admin/hooks/store/use-user.tsx b/admin/hooks/store/use-user.tsx
new file mode 100644
index 000000000..823003144
--- /dev/null
+++ b/admin/hooks/store/use-user.tsx
@@ -0,0 +1,10 @@
+import { useContext } from "react";
+// store
+import { StoreContext } from "@/lib/store-provider";
+import { IUserStore } from "@/store/user.store";
+
+export const useUser = (): IUserStore => {
+ const context = useContext(StoreContext);
+ if (context === undefined) throw new Error("useUser must be used within StoreProvider");
+ return context.user;
+};
diff --git a/admin/hooks/use-outside-click-detector.tsx b/admin/hooks/use-outside-click-detector.tsx
new file mode 100644
index 000000000..b7b48c857
--- /dev/null
+++ b/admin/hooks/use-outside-click-detector.tsx
@@ -0,0 +1,21 @@
+"use client";
+
+import React, { useEffect } from "react";
+
+const useOutsideClickDetector = (ref: React.RefObject, callback: () => void) => {
+ const handleClick = (event: MouseEvent) => {
+ if (ref.current && !ref.current.contains(event.target as Node)) {
+ callback();
+ }
+ };
+
+ useEffect(() => {
+ document.addEventListener("mousedown", handleClick);
+
+ return () => {
+ document.removeEventListener("mousedown", handleClick);
+ };
+ });
+};
+
+export default useOutsideClickDetector;
diff --git a/admin/layouts/admin-layout.tsx b/admin/layouts/admin-layout.tsx
new file mode 100644
index 000000000..bcc103217
--- /dev/null
+++ b/admin/layouts/admin-layout.tsx
@@ -0,0 +1,47 @@
+"use client";
+import { FC, ReactNode, useEffect } from "react";
+import { observer } from "mobx-react-lite";
+import { useRouter } from "next/navigation";
+// components
+import { InstanceSidebar } from "@/components/admin-sidebar";
+import { InstanceHeader } from "@/components/auth-header";
+import { LogoSpinner } from "@/components/common";
+import { NewUserPopup } from "@/components/new-user-popup";
+// hooks
+import { useUser } from "@/hooks/store";
+
+type TAdminLayout = {
+ children: ReactNode;
+};
+
+export const AdminLayout: FC = observer((props) => {
+ const { children } = props;
+ // router
+ const router = useRouter();
+ const { isUserLoggedIn } = useUser();
+
+ useEffect(() => {
+ if (isUserLoggedIn === false) {
+ router.push("/");
+ }
+ }, [router, isUserLoggedIn]);
+
+ if (isUserLoggedIn === undefined) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ );
+});
diff --git a/admin/layouts/default-layout.tsx b/admin/layouts/default-layout.tsx
new file mode 100644
index 000000000..1be40ea12
--- /dev/null
+++ b/admin/layouts/default-layout.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import { FC, ReactNode } from "react";
+import Image from "next/image";
+import Link from "next/link";
+import { useTheme } from "next-themes";
+// logo/ images
+import PlaneBackgroundPatternDark from "public/auth/background-pattern-dark.svg";
+import PlaneBackgroundPattern from "public/auth/background-pattern.svg";
+import BlackHorizontalLogo from "public/plane-logos/black-horizontal-with-blue-logo.png";
+import WhiteHorizontalLogo from "public/plane-logos/white-horizontal-with-blue-logo.png";
+
+type TDefaultLayout = {
+ children: ReactNode;
+ withoutBackground?: boolean;
+};
+
+export const DefaultLayout: FC = (props) => {
+ const { children, withoutBackground = false } = props;
+ // hooks
+ const { resolvedTheme } = useTheme();
+ const patternBackground = resolvedTheme === "dark" ? PlaneBackgroundPatternDark : PlaneBackgroundPattern;
+
+ const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo;
+
+ return (
+
+
+
+ {!withoutBackground && (
+
+
+
+ )}
+
{children}
+
+
+ );
+};
diff --git a/admin/lib/instance-provider.tsx b/admin/lib/instance-provider.tsx
new file mode 100644
index 000000000..fbcf27d82
--- /dev/null
+++ b/admin/lib/instance-provider.tsx
@@ -0,0 +1,55 @@
+import { FC, ReactNode } from "react";
+import { observer } from "mobx-react-lite";
+import useSWR from "swr";
+// components
+import { LogoSpinner } from "@/components/common";
+import { InstanceSetupForm, InstanceFailureView } from "@/components/instance";
+// hooks
+import { useInstance } from "@/hooks/store";
+// layout
+import { DefaultLayout } from "@/layouts/default-layout";
+
+type InstanceProviderProps = {
+ children: ReactNode;
+};
+
+export const InstanceProvider: FC = observer((props) => {
+ const { children } = props;
+ // store hooks
+ const { instance, error, fetchInstanceInfo } = useInstance();
+ // fetching instance details
+ useSWR("INSTANCE_DETAILS", () => fetchInstanceInfo(), {
+ revalidateOnFocus: false,
+ revalidateIfStale: false,
+ errorRetryCount: 0,
+ });
+
+ if (!instance && !error)
+ return (
+
+
+
+ );
+
+ if (error) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (!instance?.is_setup_done) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return <>{children}>;
+});
diff --git a/admin/lib/store-provider.tsx b/admin/lib/store-provider.tsx
new file mode 100644
index 000000000..842513860
--- /dev/null
+++ b/admin/lib/store-provider.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import { ReactNode, createContext } from "react";
+// store
+import { RootStore } from "@/store/root.store";
+
+let rootStore = new RootStore();
+
+export const StoreContext = createContext(rootStore);
+
+function initializeStore(initialData = {}) {
+ const singletonRootStore = rootStore ?? new RootStore();
+ // If your page has Next.js data fetching methods that use a Mobx store, it will
+ // get hydrated here, check `pages/ssg.js` and `pages/ssr.js` for more details
+ if (initialData) {
+ singletonRootStore.hydrate(initialData);
+ }
+ // For SSG and SSR always create a new store
+ if (typeof window === "undefined") return singletonRootStore;
+ // Create the store once in the client
+ if (!rootStore) rootStore = singletonRootStore;
+ return singletonRootStore;
+}
+
+export type StoreProviderProps = {
+ children: ReactNode;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ initialState?: any;
+};
+
+export const StoreProvider = ({ children, initialState = {} }: StoreProviderProps) => {
+ const store = initializeStore(initialState);
+ return {children} ;
+};
diff --git a/admin/lib/user-provider.tsx b/admin/lib/user-provider.tsx
new file mode 100644
index 000000000..d8448d13e
--- /dev/null
+++ b/admin/lib/user-provider.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import { FC, ReactNode, useEffect } from "react";
+import { observer } from "mobx-react-lite";
+import useSWR from "swr";
+// hooks
+import { useInstance, useTheme, useUser } from "@/hooks/store";
+
+interface IUserProvider {
+ children: ReactNode;
+}
+
+export const UserProvider: FC = observer(({ children }) => {
+ // hooks
+ const { isSidebarCollapsed, toggleSidebar } = useTheme();
+ const { currentUser, fetchCurrentUser } = useUser();
+ const { fetchInstanceAdmins } = useInstance();
+
+ useSWR("CURRENT_USER", () => fetchCurrentUser(), {
+ shouldRetryOnError: false,
+ });
+ useSWR("INSTANCE_ADMINS", () => fetchInstanceAdmins());
+
+ useEffect(() => {
+ const localValue = localStorage && localStorage.getItem("god_mode_sidebar_collapsed");
+ const localBoolValue = localValue ? (localValue === "true" ? true : false) : false;
+ if (isSidebarCollapsed === undefined && localBoolValue != isSidebarCollapsed) toggleSidebar(localBoolValue);
+ }, [isSidebarCollapsed, currentUser, toggleSidebar]);
+
+ return <>{children}>;
+});
diff --git a/admin/next-env.d.ts b/admin/next-env.d.ts
new file mode 100644
index 000000000..4f11a03dc
--- /dev/null
+++ b/admin/next-env.d.ts
@@ -0,0 +1,5 @@
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/basic-features/typescript for more information.
diff --git a/admin/next.config.js b/admin/next.config.js
new file mode 100644
index 000000000..07f6664af
--- /dev/null
+++ b/admin/next.config.js
@@ -0,0 +1,13 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ trailingSlash: true,
+ reactStrictMode: false,
+ swcMinify: true,
+ output: "standalone",
+ images: {
+ unoptimized: true,
+ },
+ basePath: process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "",
+};
+
+module.exports = nextConfig;
diff --git a/admin/package.json b/admin/package.json
new file mode 100644
index 000000000..1e1bc372e
--- /dev/null
+++ b/admin/package.json
@@ -0,0 +1,50 @@
+{
+ "name": "admin",
+ "version": "0.20.0",
+ "private": true,
+ "scripts": {
+ "dev": "turbo run develop",
+ "develop": "next dev --port 3001",
+ "build": "next build",
+ "preview": "next build && next start",
+ "start": "next start",
+ "lint": "next lint"
+ },
+ "dependencies": {
+ "@headlessui/react": "^1.7.19",
+ "@plane/types": "*",
+ "@plane/ui": "*",
+ "@plane/constants": "*",
+ "@tailwindcss/typography": "^0.5.9",
+ "@types/lodash": "^4.17.0",
+ "autoprefixer": "10.4.14",
+ "axios": "^1.6.7",
+ "js-cookie": "^3.0.5",
+ "lodash": "^4.17.21",
+ "lucide-react": "^0.356.0",
+ "mobx": "^6.12.0",
+ "mobx-react-lite": "^4.0.5",
+ "next": "^14.2.3",
+ "next-themes": "^0.2.1",
+ "postcss": "^8.4.38",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-hook-form": "^7.51.0",
+ "swr": "^2.2.4",
+ "tailwindcss": "3.3.2",
+ "uuid": "^9.0.1",
+ "zxcvbn": "^4.4.2"
+ },
+ "devDependencies": {
+ "@types/js-cookie": "^3.0.6",
+ "@types/node": "18.16.1",
+ "@types/react": "^18.2.48",
+ "@types/react-dom": "^18.2.18",
+ "@types/uuid": "^9.0.8",
+ "@types/zxcvbn": "^4.4.4",
+ "eslint-config-custom": "*",
+ "tailwind-config-custom": "*",
+ "tsconfig": "*",
+ "typescript": "^5.4.2"
+ }
+}
diff --git a/admin/postcss.config.js b/admin/postcss.config.js
new file mode 100644
index 000000000..6887c8262
--- /dev/null
+++ b/admin/postcss.config.js
@@ -0,0 +1,8 @@
+module.exports = {
+ plugins: {
+ "postcss-import": {},
+ "tailwindcss/nesting": {},
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/admin/public/auth/background-pattern-dark.svg b/admin/public/auth/background-pattern-dark.svg
new file mode 100644
index 000000000..c258cbabf
--- /dev/null
+++ b/admin/public/auth/background-pattern-dark.svg
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/admin/public/auth/background-pattern.svg b/admin/public/auth/background-pattern.svg
new file mode 100644
index 000000000..5fcbeec27
--- /dev/null
+++ b/admin/public/auth/background-pattern.svg
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/admin/public/favicon/android-chrome-192x192.png b/admin/public/favicon/android-chrome-192x192.png
new file mode 100644
index 000000000..62e95acfc
Binary files /dev/null and b/admin/public/favicon/android-chrome-192x192.png differ
diff --git a/admin/public/favicon/android-chrome-512x512.png b/admin/public/favicon/android-chrome-512x512.png
new file mode 100644
index 000000000..41400832b
Binary files /dev/null and b/admin/public/favicon/android-chrome-512x512.png differ
diff --git a/admin/public/favicon/apple-touch-icon.png b/admin/public/favicon/apple-touch-icon.png
new file mode 100644
index 000000000..5273d4951
Binary files /dev/null and b/admin/public/favicon/apple-touch-icon.png differ
diff --git a/admin/public/favicon/favicon-16x16.png b/admin/public/favicon/favicon-16x16.png
new file mode 100644
index 000000000..8ddbd49c0
Binary files /dev/null and b/admin/public/favicon/favicon-16x16.png differ
diff --git a/admin/public/favicon/favicon-32x32.png b/admin/public/favicon/favicon-32x32.png
new file mode 100644
index 000000000..80cbe7a68
Binary files /dev/null and b/admin/public/favicon/favicon-32x32.png differ
diff --git a/admin/public/favicon/favicon.ico b/admin/public/favicon/favicon.ico
new file mode 100644
index 000000000..9094a07c7
Binary files /dev/null and b/admin/public/favicon/favicon.ico differ
diff --git a/admin/public/favicon/site.webmanifest b/admin/public/favicon/site.webmanifest
new file mode 100644
index 000000000..0b08af126
--- /dev/null
+++ b/admin/public/favicon/site.webmanifest
@@ -0,0 +1,11 @@
+{
+ "name": "",
+ "short_name": "",
+ "icons": [
+ { "src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
+ { "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" }
+ ],
+ "theme_color": "#ffffff",
+ "background_color": "#ffffff",
+ "display": "standalone"
+}
diff --git a/admin/public/images/logo-spinner-dark.gif b/admin/public/images/logo-spinner-dark.gif
new file mode 100644
index 000000000..4e0a1deb7
Binary files /dev/null and b/admin/public/images/logo-spinner-dark.gif differ
diff --git a/admin/public/images/logo-spinner-light.gif b/admin/public/images/logo-spinner-light.gif
new file mode 100644
index 000000000..7c9bfbe0e
Binary files /dev/null and b/admin/public/images/logo-spinner-light.gif differ
diff --git a/admin/public/images/plane-takeoff.png b/admin/public/images/plane-takeoff.png
new file mode 100644
index 000000000..417ff8299
Binary files /dev/null and b/admin/public/images/plane-takeoff.png differ
diff --git a/admin/public/instance/instance-failure-dark.svg b/admin/public/instance/instance-failure-dark.svg
new file mode 100644
index 000000000..58d691705
--- /dev/null
+++ b/admin/public/instance/instance-failure-dark.svg
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/admin/public/instance/instance-failure.svg b/admin/public/instance/instance-failure.svg
new file mode 100644
index 000000000..a59862283
--- /dev/null
+++ b/admin/public/instance/instance-failure.svg
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/admin/public/instance/plane-takeoff.png b/admin/public/instance/plane-takeoff.png
new file mode 100644
index 000000000..417ff8299
Binary files /dev/null and b/admin/public/instance/plane-takeoff.png differ
diff --git a/admin/public/logos/github-black.png b/admin/public/logos/github-black.png
new file mode 100644
index 000000000..7a7a82474
Binary files /dev/null and b/admin/public/logos/github-black.png differ
diff --git a/admin/public/logos/github-white.png b/admin/public/logos/github-white.png
new file mode 100644
index 000000000..dbb2b578c
Binary files /dev/null and b/admin/public/logos/github-white.png differ
diff --git a/admin/public/logos/google-logo.svg b/admin/public/logos/google-logo.svg
new file mode 100644
index 000000000..088288fa3
--- /dev/null
+++ b/admin/public/logos/google-logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/admin/public/logos/takeoff-icon-dark.svg b/admin/public/logos/takeoff-icon-dark.svg
new file mode 100644
index 000000000..d3ef19119
--- /dev/null
+++ b/admin/public/logos/takeoff-icon-dark.svg
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/admin/public/logos/takeoff-icon-light.svg b/admin/public/logos/takeoff-icon-light.svg
new file mode 100644
index 000000000..97cf43fe7
--- /dev/null
+++ b/admin/public/logos/takeoff-icon-light.svg
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/admin/public/plane-logos/black-horizontal-with-blue-logo.png b/admin/public/plane-logos/black-horizontal-with-blue-logo.png
new file mode 100644
index 000000000..c14505a6f
Binary files /dev/null and b/admin/public/plane-logos/black-horizontal-with-blue-logo.png differ
diff --git a/admin/public/plane-logos/blue-without-text.png b/admin/public/plane-logos/blue-without-text.png
new file mode 100644
index 000000000..ea94aec79
Binary files /dev/null and b/admin/public/plane-logos/blue-without-text.png differ
diff --git a/admin/public/plane-logos/white-horizontal-with-blue-logo.png b/admin/public/plane-logos/white-horizontal-with-blue-logo.png
new file mode 100644
index 000000000..97560fb9f
Binary files /dev/null and b/admin/public/plane-logos/white-horizontal-with-blue-logo.png differ
diff --git a/admin/public/site.webmanifest.json b/admin/public/site.webmanifest.json
new file mode 100644
index 000000000..6e5e438f8
--- /dev/null
+++ b/admin/public/site.webmanifest.json
@@ -0,0 +1,13 @@
+{
+ "name": "Plane God Mode",
+ "short_name": "Plane God Mode",
+ "description": "Plane helps you plan your issues, cycles, and product modules.",
+ "start_url": ".",
+ "display": "standalone",
+ "background_color": "#f9fafb",
+ "theme_color": "#3f76ff",
+ "icons": [
+ { "src": "/favicon/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
+ { "src": "/favicon/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" }
+ ]
+}
diff --git a/admin/services/api.service.ts b/admin/services/api.service.ts
new file mode 100644
index 000000000..fa45c10b7
--- /dev/null
+++ b/admin/services/api.service.ts
@@ -0,0 +1,53 @@
+import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
+// store
+// import { rootStore } from "@/lib/store-context";
+
+export abstract class APIService {
+ protected baseURL: string;
+ private axiosInstance: AxiosInstance;
+
+ constructor(baseURL: string) {
+ this.baseURL = baseURL;
+ this.axiosInstance = axios.create({
+ baseURL,
+ withCredentials: true,
+ });
+
+ this.setupInterceptors();
+ }
+
+ private setupInterceptors() {
+ // this.axiosInstance.interceptors.response.use(
+ // (response) => response,
+ // (error) => {
+ // const store = rootStore;
+ // if (error.response && error.response.status === 401 && store.user.currentUser) store.user.reset();
+ // return Promise.reject(error);
+ // }
+ // );
+ }
+
+ get(url: string, params = {}): Promise> {
+ return this.axiosInstance.get(url, { params });
+ }
+
+ post(url: string, data: RequestType, config = {}): Promise> {
+ return this.axiosInstance.post(url, data, config);
+ }
+
+ put(url: string, data: RequestType, config = {}): Promise> {
+ return this.axiosInstance.put(url, data, config);
+ }
+
+ patch(url: string, data: RequestType, config = {}): Promise> {
+ return this.axiosInstance.patch(url, data, config);
+ }
+
+ delete(url: string, data?: RequestType, config = {}) {
+ return this.axiosInstance.delete(url, { data, ...config });
+ }
+
+ request(config: AxiosRequestConfig = {}): Promise> {
+ return this.axiosInstance(config);
+ }
+}
diff --git a/admin/services/auth.service.ts b/admin/services/auth.service.ts
new file mode 100644
index 000000000..ef7b7b151
--- /dev/null
+++ b/admin/services/auth.service.ts
@@ -0,0 +1,22 @@
+// helpers
+import { API_BASE_URL } from "helpers/common.helper";
+// services
+import { APIService } from "services/api.service";
+
+type TCsrfTokenResponse = {
+ csrf_token: string;
+};
+
+export class AuthService extends APIService {
+ constructor() {
+ super(API_BASE_URL);
+ }
+
+ async requestCSRFToken(): Promise {
+ return this.get("/auth/get-csrf-token/")
+ .then((response) => response.data)
+ .catch((error) => {
+ throw error;
+ });
+ }
+}
diff --git a/admin/services/instance.service.ts b/admin/services/instance.service.ts
new file mode 100644
index 000000000..feb94ceea
--- /dev/null
+++ b/admin/services/instance.service.ts
@@ -0,0 +1,72 @@
+// types
+import type {
+ IFormattedInstanceConfiguration,
+ IInstance,
+ IInstanceAdmin,
+ IInstanceConfiguration,
+ IInstanceInfo,
+} from "@plane/types";
+// helpers
+import { API_BASE_URL } from "@/helpers/common.helper";
+import { APIService } from "@/services/api.service";
+
+export class InstanceService extends APIService {
+ constructor() {
+ super(API_BASE_URL);
+ }
+
+ async getInstanceInfo(): Promise {
+ return this.get("/api/instances/")
+ .then((response) => response.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async getInstanceAdmins(): Promise {
+ return this.get("/api/instances/admins/")
+ .then((response) => response.data)
+ .catch((error) => {
+ throw error;
+ });
+ }
+
+ async updateInstanceInfo(data: Partial): Promise {
+ return this.patch, IInstance>("/api/instances/", data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async getInstanceConfigurations() {
+ return this.get("/api/instances/configurations/")
+ .then((response) => response.data)
+ .catch((error) => {
+ throw error;
+ });
+ }
+
+ async updateInstanceConfigurations(
+ data: Partial
+ ): Promise {
+ return this.patch, IInstanceConfiguration[]>(
+ "/api/instances/configurations/",
+ data
+ )
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async sendTestEmail(receiverEmail: string): Promise {
+ return this.post<{ receiver_email: string }, undefined>("/api/instances/email-credentials-check/", {
+ receiver_email: receiverEmail,
+ })
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+}
diff --git a/admin/services/user.service.ts b/admin/services/user.service.ts
new file mode 100644
index 000000000..bef384daf
--- /dev/null
+++ b/admin/services/user.service.ts
@@ -0,0 +1,30 @@
+// helpers
+import { API_BASE_URL } from "helpers/common.helper";
+// services
+import { APIService } from "services/api.service";
+// types
+import type { IUser } from "@plane/types";
+
+interface IUserSession extends IUser {
+ isAuthenticated: boolean;
+}
+
+export class UserService extends APIService {
+ constructor() {
+ super(API_BASE_URL);
+ }
+
+ async authCheck(): Promise {
+ return this.get("/api/instances/admins/me/")
+ .then((response) => ({ ...response?.data, isAuthenticated: true }))
+ .catch(() => ({ isAuthenticated: false }));
+ }
+
+ async currentUser(): Promise {
+ return this.get("/api/instances/admins/me/")
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response;
+ });
+ }
+}
diff --git a/admin/store/instance.store.ts b/admin/store/instance.store.ts
new file mode 100644
index 000000000..a99cd808c
--- /dev/null
+++ b/admin/store/instance.store.ts
@@ -0,0 +1,191 @@
+import set from "lodash/set";
+import { observable, action, computed, makeObservable, runInAction } from "mobx";
+import {
+ IInstance,
+ IInstanceAdmin,
+ IInstanceConfiguration,
+ IFormattedInstanceConfiguration,
+ IInstanceInfo,
+ IInstanceConfig,
+} from "@plane/types";
+// helpers
+import { EInstanceStatus, TInstanceStatus } from "@/helpers";
+// services
+import { InstanceService } from "@/services/instance.service";
+// root store
+import { RootStore } from "@/store/root.store";
+
+export interface IInstanceStore {
+ // issues
+ isLoading: boolean;
+ error: any;
+ instanceStatus: TInstanceStatus | undefined;
+ instance: IInstance | undefined;
+ config: IInstanceConfig | undefined;
+ instanceAdmins: IInstanceAdmin[] | undefined;
+ instanceConfigurations: IInstanceConfiguration[] | undefined;
+ // computed
+ formattedConfig: IFormattedInstanceConfiguration | undefined;
+ // action
+ hydrate: (data: IInstanceInfo) => void;
+ fetchInstanceInfo: () => Promise;
+ updateInstanceInfo: (data: Partial) => Promise;
+ fetchInstanceAdmins: () => Promise;
+ fetchInstanceConfigurations: () => Promise;
+ updateInstanceConfigurations: (data: Partial) => Promise;
+}
+
+export class InstanceStore implements IInstanceStore {
+ isLoading: boolean = true;
+ error: any = undefined;
+ instanceStatus: TInstanceStatus | undefined = undefined;
+ instance: IInstance | undefined = undefined;
+ config: IInstanceConfig | undefined = undefined;
+ instanceAdmins: IInstanceAdmin[] | undefined = undefined;
+ instanceConfigurations: IInstanceConfiguration[] | undefined = undefined;
+ // service
+ instanceService;
+
+ constructor(private store: RootStore) {
+ makeObservable(this, {
+ // observable
+ isLoading: observable.ref,
+ error: observable.ref,
+ instanceStatus: observable,
+ instance: observable,
+ instanceAdmins: observable,
+ instanceConfigurations: observable,
+ // computed
+ formattedConfig: computed,
+ // actions
+ hydrate: action,
+ fetchInstanceInfo: action,
+ fetchInstanceAdmins: action,
+ updateInstanceInfo: action,
+ fetchInstanceConfigurations: action,
+ updateInstanceConfigurations: action,
+ });
+
+ this.instanceService = new InstanceService();
+ }
+
+ hydrate = (data: IInstanceInfo) => {
+ if (data) {
+ this.instance = data.instance;
+ this.config = data.config;
+ }
+ };
+
+ /**
+ * computed value for instance configurations data for forms.
+ * @returns configurations in the form of {key, value} pair.
+ */
+ get formattedConfig() {
+ if (!this.instanceConfigurations) return undefined;
+ return this.instanceConfigurations?.reduce((formData: IFormattedInstanceConfiguration, config) => {
+ formData[config.key] = config.value;
+ return formData;
+ }, {} as IFormattedInstanceConfiguration);
+ }
+
+ /**
+ * @description fetching instance configuration
+ * @returns {IInstance} instance
+ */
+ fetchInstanceInfo = async () => {
+ try {
+ if (this.instance === undefined) this.isLoading = true;
+ this.error = undefined;
+ const instanceInfo = await this.instanceService.getInstanceInfo();
+ // handling the new user popup toggle
+ if (this.instance === undefined && !instanceInfo?.instance?.workspaces_exist)
+ this.store.theme.toggleNewUserPopup();
+ runInAction(() => {
+ console.log("instanceInfo: ", instanceInfo);
+ this.isLoading = false;
+ this.instance = instanceInfo.instance;
+ this.config = instanceInfo.config;
+ });
+ return instanceInfo;
+ } catch (error) {
+ console.error("Error fetching the instance info");
+ this.isLoading = false;
+ this.error = { message: "Failed to fetch the instance info" };
+ this.instanceStatus = {
+ status: EInstanceStatus.ERROR,
+ };
+ throw error;
+ }
+ };
+
+ /**
+ * @description updating instance information
+ * @param {Partial} data
+ * @returns void
+ */
+ updateInstanceInfo = async (data: Partial) => {
+ try {
+ const instanceResponse = await this.instanceService.updateInstanceInfo(data);
+ if (instanceResponse) {
+ runInAction(() => {
+ if (this.instance) set(this.instance, "instance", instanceResponse);
+ });
+ }
+ return instanceResponse;
+ } catch (error) {
+ console.error("Error updating the instance info");
+ throw error;
+ }
+ };
+
+ /**
+ * @description fetching instance admins
+ * @return {IInstanceAdmin[]} instanceAdmins
+ */
+ fetchInstanceAdmins = async () => {
+ try {
+ const instanceAdmins = await this.instanceService.getInstanceAdmins();
+ if (instanceAdmins) runInAction(() => (this.instanceAdmins = instanceAdmins));
+ return instanceAdmins;
+ } catch (error) {
+ console.error("Error fetching the instance admins");
+ throw error;
+ }
+ };
+
+ /**
+ * @description fetching instance configurations
+ * @return {IInstanceAdmin[]} instanceConfigurations
+ */
+ fetchInstanceConfigurations = async () => {
+ try {
+ const instanceConfigurations = await this.instanceService.getInstanceConfigurations();
+ if (instanceConfigurations) runInAction(() => (this.instanceConfigurations = instanceConfigurations));
+ return instanceConfigurations;
+ } catch (error) {
+ console.error("Error fetching the instance configurations");
+ throw error;
+ }
+ };
+
+ /**
+ * @description updating instance configurations
+ * @param data
+ */
+ updateInstanceConfigurations = async (data: Partial) => {
+ try {
+ const response = await this.instanceService.updateInstanceConfigurations(data);
+ runInAction(() => {
+ this.instanceConfigurations = this.instanceConfigurations?.map((config) => {
+ const item = response.find((item) => item.key === config.key);
+ if (item) return item;
+ return config;
+ });
+ });
+ return response;
+ } catch (error) {
+ console.error("Error updating the instance configurations");
+ throw error;
+ }
+ };
+}
diff --git a/admin/store/root.store.ts b/admin/store/root.store.ts
new file mode 100644
index 000000000..553a22200
--- /dev/null
+++ b/admin/store/root.store.ts
@@ -0,0 +1,32 @@
+import { enableStaticRendering } from "mobx-react-lite";
+// stores
+import { IInstanceStore, InstanceStore } from "./instance.store";
+import { IThemeStore, ThemeStore } from "./theme.store";
+import { IUserStore, UserStore } from "./user.store";
+
+enableStaticRendering(typeof window === "undefined");
+
+export class RootStore {
+ theme: IThemeStore;
+ instance: IInstanceStore;
+ user: IUserStore;
+
+ constructor() {
+ this.theme = new ThemeStore(this);
+ this.instance = new InstanceStore(this);
+ this.user = new UserStore(this);
+ }
+
+ hydrate(initialData: any) {
+ this.theme.hydrate(initialData.theme);
+ this.instance.hydrate(initialData.instance);
+ this.user.hydrate(initialData.user);
+ }
+
+ resetOnSignOut() {
+ localStorage.setItem("theme", "system");
+ this.instance = new InstanceStore(this);
+ this.user = new UserStore(this);
+ this.theme = new ThemeStore(this);
+ }
+}
diff --git a/admin/store/theme.store.ts b/admin/store/theme.store.ts
new file mode 100644
index 000000000..a3f3b3d5a
--- /dev/null
+++ b/admin/store/theme.store.ts
@@ -0,0 +1,68 @@
+import { action, observable, makeObservable } from "mobx";
+// root store
+import { RootStore } from "@/store/root.store";
+
+type TTheme = "dark" | "light";
+export interface IThemeStore {
+ // observables
+ isNewUserPopup: boolean;
+ theme: string | undefined;
+ isSidebarCollapsed: boolean | undefined;
+ // actions
+ hydrate: (data: any) => void;
+ toggleNewUserPopup: () => void;
+ toggleSidebar: (collapsed: boolean) => void;
+ setTheme: (currentTheme: TTheme) => void;
+}
+
+export class ThemeStore implements IThemeStore {
+ // observables
+ isNewUserPopup: boolean = false;
+ isSidebarCollapsed: boolean | undefined = undefined;
+ theme: string | undefined = undefined;
+
+ constructor(private store: RootStore) {
+ makeObservable(this, {
+ // observables
+ isNewUserPopup: observable.ref,
+ isSidebarCollapsed: observable.ref,
+ theme: observable.ref,
+ // action
+ toggleNewUserPopup: action,
+ toggleSidebar: action,
+ setTheme: action,
+ });
+ }
+
+ hydrate = (data: any) => {
+ if (data) this.theme = data;
+ };
+
+ /**
+ * @description Toggle the new user popup modal
+ */
+ toggleNewUserPopup = () => (this.isNewUserPopup = !this.isNewUserPopup);
+
+ /**
+ * @description Toggle the sidebar collapsed state
+ * @param isCollapsed
+ */
+ toggleSidebar = (isCollapsed: boolean) => {
+ if (isCollapsed === undefined) this.isSidebarCollapsed = !this.isSidebarCollapsed;
+ else this.isSidebarCollapsed = isCollapsed;
+ localStorage.setItem("god_mode_sidebar_collapsed", isCollapsed.toString());
+ };
+
+ /**
+ * @description Sets the user theme and applies it to the platform
+ * @param currentTheme
+ */
+ setTheme = async (currentTheme: TTheme) => {
+ try {
+ localStorage.setItem("theme", currentTheme);
+ this.theme = currentTheme;
+ } catch (error) {
+ console.error("setting user theme error", error);
+ }
+ };
+}
diff --git a/admin/store/user.store.ts b/admin/store/user.store.ts
new file mode 100644
index 000000000..60638f0cd
--- /dev/null
+++ b/admin/store/user.store.ts
@@ -0,0 +1,104 @@
+import { action, observable, runInAction, makeObservable } from "mobx";
+import { IUser } from "@plane/types";
+// helpers
+import { EUserStatus, TUserStatus } from "@/helpers";
+// services
+import { AuthService } from "@/services/auth.service";
+import { UserService } from "@/services/user.service";
+// root store
+import { RootStore } from "@/store/root.store";
+
+export interface IUserStore {
+ // observables
+ isLoading: boolean;
+ userStatus: TUserStatus | undefined;
+ isUserLoggedIn: boolean | undefined;
+ currentUser: IUser | undefined;
+ // fetch actions
+ hydrate: (data: any) => void;
+ fetchCurrentUser: () => Promise;
+ reset: () => void;
+ signOut: () => void;
+}
+
+export class UserStore implements IUserStore {
+ // observables
+ isLoading: boolean = true;
+ userStatus: TUserStatus | undefined = undefined;
+ isUserLoggedIn: boolean | undefined = undefined;
+ currentUser: IUser | undefined = undefined;
+ // services
+ userService;
+ authService;
+
+ constructor(private store: RootStore) {
+ makeObservable(this, {
+ // observables
+ isLoading: observable.ref,
+ userStatus: observable,
+ isUserLoggedIn: observable.ref,
+ currentUser: observable,
+ // action
+ fetchCurrentUser: action,
+ reset: action,
+ signOut: action,
+ });
+ this.userService = new UserService();
+ this.authService = new AuthService();
+ }
+
+ hydrate = (data: any) => {
+ if (data) this.currentUser = data;
+ };
+
+ /**
+ * @description Fetches the current user
+ * @returns Promise
+ */
+ fetchCurrentUser = async () => {
+ try {
+ if (this.currentUser === undefined) this.isLoading = true;
+ const currentUser = await this.userService.currentUser();
+ if (currentUser) {
+ await this.store.instance.fetchInstanceAdmins();
+ runInAction(() => {
+ this.isUserLoggedIn = true;
+ this.currentUser = currentUser;
+ this.isLoading = false;
+ });
+ } else {
+ runInAction(() => {
+ this.isUserLoggedIn = false;
+ this.currentUser = undefined;
+ this.isLoading = false;
+ });
+ }
+ return currentUser;
+ } catch (error: any) {
+ this.isLoading = false;
+ this.isUserLoggedIn = false;
+ if (error.status === 403)
+ this.userStatus = {
+ status: EUserStatus.AUTHENTICATION_NOT_DONE,
+ message: error?.message || "",
+ };
+ else
+ this.userStatus = {
+ status: EUserStatus.ERROR,
+ message: error?.message || "",
+ };
+ throw error;
+ }
+ };
+
+ reset = async () => {
+ this.isUserLoggedIn = false;
+ this.currentUser = undefined;
+ this.isLoading = false;
+ this.userStatus = undefined;
+ };
+
+ signOut = async () => {
+ this.store.resetOnSignOut();
+ };
+}
diff --git a/admin/tailwind.config.js b/admin/tailwind.config.js
new file mode 100644
index 000000000..05bc93bdc
--- /dev/null
+++ b/admin/tailwind.config.js
@@ -0,0 +1,5 @@
+const sharedConfig = require("tailwind-config-custom/tailwind.config.js");
+
+module.exports = {
+ presets: [sharedConfig],
+};
diff --git a/admin/tsconfig.json b/admin/tsconfig.json
new file mode 100644
index 000000000..5bc5a5684
--- /dev/null
+++ b/admin/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "extends": "tsconfig/nextjs.json",
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"],
+ "compilerOptions": {
+ "baseUrl": ".",
+ "jsx": "preserve",
+ "esModuleInterop": true,
+ "paths": {
+ "@/*": ["*"]
+ },
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ]
+ }
+}
diff --git a/apiserver/.env.example b/apiserver/.env.example
index d8554f400..38944f79c 100644
--- a/apiserver/.env.example
+++ b/apiserver/.env.example
@@ -1,7 +1,7 @@
# Backend
# Debug value for api server use it as 0 for production use
DEBUG=0
-CORS_ALLOWED_ORIGINS=""
+CORS_ALLOWED_ORIGINS="http://localhost"
# Error logs
SENTRY_DSN=""
@@ -12,7 +12,8 @@ POSTGRES_USER="plane"
POSTGRES_PASSWORD="plane"
POSTGRES_HOST="plane-db"
POSTGRES_DB="plane"
-DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}/${POSTGRES_DB}
+POSTGRES_PORT=5432
+DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
# Redis Settings
@@ -44,3 +45,8 @@ WEB_URL="http://localhost"
# Gunicorn Workers
GUNICORN_WORKERS=2
+
+# Base URLs
+ADMIN_BASE_URL=
+SPACE_BASE_URL=
+APP_BASE_URL=
diff --git a/apiserver/Dockerfile.api b/apiserver/Dockerfile.api
index 31124c8f5..6447e9f97 100644
--- a/apiserver/Dockerfile.api
+++ b/apiserver/Dockerfile.api
@@ -42,11 +42,10 @@ RUN apk --no-cache add "bash~=5.2"
COPY ./bin ./bin/
RUN mkdir -p /code/plane/logs
-RUN chmod +x ./bin/takeoff ./bin/worker ./bin/beat
+RUN chmod +x ./bin/*
RUN chmod -R 777 /code
# Expose container port and run entry point script
EXPOSE 8000
-# CMD [ "./bin/takeoff" ]
diff --git a/apiserver/Dockerfile.dev b/apiserver/Dockerfile.dev
index 6a225fec3..3de300db7 100644
--- a/apiserver/Dockerfile.dev
+++ b/apiserver/Dockerfile.dev
@@ -41,5 +41,5 @@ RUN chmod -R 777 /code
# Expose container port and run entry point script
EXPOSE 8000
-CMD [ "./bin/takeoff.local" ]
+CMD [ "./bin/docker-entrypoint-api-local.sh" ]
diff --git a/apiserver/bin/takeoff.local b/apiserver/bin/docker-entrypoint-api-local.sh
similarity index 100%
rename from apiserver/bin/takeoff.local
rename to apiserver/bin/docker-entrypoint-api-local.sh
diff --git a/apiserver/bin/takeoff b/apiserver/bin/docker-entrypoint-api.sh
similarity index 100%
rename from apiserver/bin/takeoff
rename to apiserver/bin/docker-entrypoint-api.sh
diff --git a/apiserver/bin/beat b/apiserver/bin/docker-entrypoint-beat.sh
old mode 100755
new mode 100644
similarity index 100%
rename from apiserver/bin/beat
rename to apiserver/bin/docker-entrypoint-beat.sh
diff --git a/apiserver/bin/docker-entrypoint-migrator.sh b/apiserver/bin/docker-entrypoint-migrator.sh
new file mode 100644
index 000000000..104b39024
--- /dev/null
+++ b/apiserver/bin/docker-entrypoint-migrator.sh
@@ -0,0 +1,6 @@
+#!/bin/bash
+set -e
+
+python manage.py wait_for_db $1
+
+python manage.py migrate $1
\ No newline at end of file
diff --git a/apiserver/bin/worker b/apiserver/bin/docker-entrypoint-worker.sh
similarity index 100%
rename from apiserver/bin/worker
rename to apiserver/bin/docker-entrypoint-worker.sh
diff --git a/apiserver/package.json b/apiserver/package.json
index d357d5cb4..317e82033 100644
--- a/apiserver/package.json
+++ b/apiserver/package.json
@@ -1,4 +1,4 @@
{
"name": "plane-api",
- "version": "0.19.0"
+ "version": "0.20.0"
}
diff --git a/apiserver/plane/api/serializers/inbox.py b/apiserver/plane/api/serializers/inbox.py
index 78bb74d13..a0c79235d 100644
--- a/apiserver/plane/api/serializers/inbox.py
+++ b/apiserver/plane/api/serializers/inbox.py
@@ -1,9 +1,13 @@
# Module improts
from .base import BaseSerializer
+from .issue import IssueExpandSerializer
from plane.db.models import InboxIssue
class InboxIssueSerializer(BaseSerializer):
+
+ issue_detail = IssueExpandSerializer(read_only=True, source="issue")
+
class Meta:
model = InboxIssue
fields = "__all__"
diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py
index c40f56ccc..020917ee5 100644
--- a/apiserver/plane/api/serializers/issue.py
+++ b/apiserver/plane/api/serializers/issue.py
@@ -315,7 +315,7 @@ class IssueLinkSerializer(BaseSerializer):
if IssueLink.objects.filter(
url=validated_data.get("url"),
issue_id=instance.issue_id,
- ).exists():
+ ).exclude(pk=instance.id).exists():
raise serializers.ValidationError(
{"error": "URL already exists for this Issue"}
)
diff --git a/apiserver/plane/api/views/base.py b/apiserver/plane/api/views/base.py
index 13047eb78..fee508a30 100644
--- a/apiserver/plane/api/views/base.py
+++ b/apiserver/plane/api/views/base.py
@@ -1,6 +1,4 @@
# Python imports
-from urllib.parse import urlparse
-
import zoneinfo
# Django imports
@@ -19,7 +17,6 @@ from rest_framework.views import APIView
# Module imports
from plane.api.middleware.api_authentication import APIKeyAuthentication
from plane.api.rate_limit import ApiKeyRateThrottle
-from plane.bgtasks.webhook_task import send_webhook
from plane.utils.exception_logger import log_exception
from plane.utils.paginator import BasePaginator
@@ -38,40 +35,6 @@ class TimezoneMixin:
timezone.deactivate()
-class WebhookMixin:
- webhook_event = None
- bulk = False
-
- def finalize_response(self, request, response, *args, **kwargs):
- response = super().finalize_response(
- request, response, *args, **kwargs
- )
-
- # Check for the case should webhook be sent
- if (
- self.webhook_event
- and self.request.method in ["POST", "PATCH", "DELETE"]
- and response.status_code in [200, 201, 204]
- ):
- url = request.build_absolute_uri()
- parsed_url = urlparse(url)
- # Extract the scheme and netloc
- scheme = parsed_url.scheme
- netloc = parsed_url.netloc
- # Push the object to delay
- send_webhook.delay(
- event=self.webhook_event,
- payload=response.data,
- kw=self.kwargs,
- action=self.request.method,
- slug=self.workspace_slug,
- bulk=self.bulk,
- current_site=f"{scheme}://{netloc}",
- )
-
- return response
-
-
class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
authentication_classes = [
APIKeyAuthentication,
diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py
index d9c75ff41..6e1e5e057 100644
--- a/apiserver/plane/api/views/cycle.py
+++ b/apiserver/plane/api/views/cycle.py
@@ -5,6 +5,7 @@ import json
from django.core import serializers
from django.db.models import Count, F, Func, OuterRef, Q, Sum
from django.utils import timezone
+from django.core.serializers.json import DjangoJSONEncoder
# Third party imports
from rest_framework import status
@@ -26,10 +27,11 @@ from plane.db.models import (
)
from plane.utils.analytics_plot import burndown_plot
-from .base import BaseAPIView, WebhookMixin
+from .base import BaseAPIView
+from plane.bgtasks.webhook_task import model_activity
-class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
+class CycleAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to cycle.
@@ -277,6 +279,16 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
project_id=project_id,
owned_by=request.user,
)
+ # Send the model activity
+ model_activity.delay(
+ model_name="cycle",
+ model_id=str(serializer.data["id"]),
+ requested_data=request.data,
+ current_instance=None,
+ actor_id=request.user.id,
+ slug=slug,
+ origin=request.META.get("HTTP_ORIGIN"),
+ )
return Response(
serializer.data, status=status.HTTP_201_CREATED
)
@@ -295,6 +307,11 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
cycle = Cycle.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
+
+ current_instance = json.dumps(
+ CycleSerializer(cycle).data, cls=DjangoJSONEncoder
+ )
+
if cycle.archived_at:
return Response(
{"error": "Archived cycle cannot be edited"},
@@ -344,6 +361,17 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_409_CONFLICT,
)
serializer.save()
+
+ # Send the model activity
+ model_activity.delay(
+ model_name="cycle",
+ model_id=str(serializer.data["id"]),
+ requested_data=request.data,
+ current_instance=current_instance,
+ actor_id=request.user.id,
+ slug=slug,
+ origin=request.META.get("HTTP_ORIGIN"),
+ )
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -515,7 +543,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT)
-class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
+class CycleIssueAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`,
and `destroy` actions related to cycle issues.
diff --git a/apiserver/plane/api/views/inbox.py b/apiserver/plane/api/views/inbox.py
index 5e6e4a215..8987e4f63 100644
--- a/apiserver/plane/api/views/inbox.py
+++ b/apiserver/plane/api/views/inbox.py
@@ -154,6 +154,13 @@ class InboxIssueAPIEndpoint(BaseAPIView):
state=state,
)
+ # create an inbox issue
+ inbox_issue = InboxIssue.objects.create(
+ inbox_id=inbox.id,
+ project_id=project_id,
+ issue=issue,
+ source=request.data.get("source", "in-app"),
+ )
# Create an Issue Activity
issue_activity.delay(
type="issue.activity.created",
@@ -163,14 +170,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
- )
-
- # create an inbox issue
- inbox_issue = InboxIssue.objects.create(
- inbox_id=inbox.id,
- project_id=project_id,
- issue=issue,
- source=request.data.get("source", "in-app"),
+ inbox=str(inbox_issue.id),
)
serializer = InboxIssueSerializer(inbox_issue)
@@ -260,6 +260,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp()),
+ inbox=(inbox_issue.id),
)
issue_serializer.save()
else:
@@ -327,6 +328,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
epoch=int(timezone.now().timestamp()),
notification=False,
origin=request.META.get("HTTP_ORIGIN"),
+ inbox=str(inbox_issue.id),
)
return Response(serializer.data, status=status.HTTP_200_OK)
diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py
index 8d72ac5db..a62278b19 100644
--- a/apiserver/plane/api/views/issue.py
+++ b/apiserver/plane/api/views/issue.py
@@ -48,11 +48,10 @@ from plane.db.models import (
ProjectMember,
)
-from .base import BaseAPIView, WebhookMixin
+from .base import BaseAPIView
-
-class WorkspaceIssueAPIEndpoint(WebhookMixin, BaseAPIView):
+class WorkspaceIssueAPIEndpoint(BaseAPIView):
"""
This viewset provides `retrieveByIssueId` on workspace level
@@ -60,12 +59,9 @@ class WorkspaceIssueAPIEndpoint(WebhookMixin, BaseAPIView):
model = Issue
webhook_event = "issue"
- permission_classes = [
- ProjectEntityPermission
- ]
+ permission_classes = [ProjectEntityPermission]
serializer_class = IssueSerializer
-
@property
def project__identifier(self):
return self.kwargs.get("project__identifier", None)
@@ -91,7 +87,9 @@ class WorkspaceIssueAPIEndpoint(WebhookMixin, BaseAPIView):
.order_by(self.kwargs.get("order_by", "-created_at"))
).distinct()
- def get(self, request, slug, project__identifier=None, issue__identifier=None):
+ def get(
+ self, request, slug, project__identifier=None, issue__identifier=None
+ ):
if issue__identifier and project__identifier:
issue = Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(
@@ -100,7 +98,11 @@ class WorkspaceIssueAPIEndpoint(WebhookMixin, BaseAPIView):
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
- ).get(workspace__slug=slug, project__identifier=project__identifier, sequence_id=issue__identifier)
+ ).get(
+ workspace__slug=slug,
+ project__identifier=project__identifier,
+ sequence_id=issue__identifier,
+ )
return Response(
IssueSerializer(
issue,
@@ -110,7 +112,8 @@ class WorkspaceIssueAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_200_OK,
)
-class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
+
+class IssueAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to issue.
@@ -652,7 +655,7 @@ class IssueLinkAPIEndpoint(BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT)
-class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
+class IssueCommentAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to comments of the particular issue.
diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py
index 38744eaa5..eeb29dad2 100644
--- a/apiserver/plane/api/views/module.py
+++ b/apiserver/plane/api/views/module.py
@@ -5,6 +5,7 @@ import json
from django.core import serializers
from django.db.models import Count, F, Func, OuterRef, Prefetch, Q
from django.utils import timezone
+from django.core.serializers.json import DjangoJSONEncoder
# Third party imports
from rest_framework import status
@@ -28,10 +29,11 @@ from plane.db.models import (
Project,
)
-from .base import BaseAPIView, WebhookMixin
+from .base import BaseAPIView
+from plane.bgtasks.webhook_task import model_activity
-class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
+class ModuleAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to module.
@@ -163,6 +165,16 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_409_CONFLICT,
)
serializer.save()
+ # Send the model activity
+ model_activity.delay(
+ model_name="module",
+ model_id=str(serializer.data["id"]),
+ requested_data=request.data,
+ current_instance=None,
+ actor_id=request.user.id,
+ slug=slug,
+ origin=request.META.get("HTTP_ORIGIN"),
+ )
module = Module.objects.get(pk=serializer.data["id"])
serializer = ModuleSerializer(module)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@@ -172,6 +184,11 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
module = Module.objects.get(
pk=pk, project_id=project_id, workspace__slug=slug
)
+
+ current_instance = json.dumps(
+ ModuleSerializer(module).data, cls=DjangoJSONEncoder
+ )
+
if module.archived_at:
return Response(
{"error": "Archived module cannot be edited"},
@@ -204,6 +221,18 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_409_CONFLICT,
)
serializer.save()
+
+ # Send the model activity
+ model_activity.delay(
+ model_name="module",
+ model_id=str(serializer.data["id"]),
+ requested_data=request.data,
+ current_instance=current_instance,
+ actor_id=request.user.id,
+ slug=slug,
+ origin=request.META.get("HTTP_ORIGIN"),
+ )
+
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -260,7 +289,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT)
-class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
+class ModuleIssueAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to module issues.
diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py
index fcb0cc4fb..019ab704e 100644
--- a/apiserver/plane/api/views/project.py
+++ b/apiserver/plane/api/views/project.py
@@ -1,7 +1,11 @@
+# Python imports
+import json
+
# Django imports
from django.db import IntegrityError
from django.db.models import Exists, F, Func, OuterRef, Prefetch, Q, Subquery
from django.utils import timezone
+from django.core.serializers.json import DjangoJSONEncoder
# Third party imports
from rest_framework import status
@@ -23,11 +27,11 @@ from plane.db.models import (
State,
Workspace,
)
-
-from .base import BaseAPIView, WebhookMixin
+from plane.bgtasks.webhook_task import model_activity
+from .base import BaseAPIView
-class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
+class ProjectAPIEndpoint(BaseAPIView):
"""Project Endpoints to create, update, list, retrieve and delete endpoint"""
serializer_class = ProjectSerializer
@@ -236,6 +240,17 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
.filter(pk=serializer.data["id"])
.first()
)
+ # Model activity
+ model_activity.delay(
+ model_name="project",
+ model_id=str(project.id),
+ requested_data=request.data,
+ current_instance=None,
+ actor_id=request.user.id,
+ slug=slug,
+ origin=request.META.get("HTTP_ORIGIN"),
+ )
+
serializer = ProjectSerializer(project)
return Response(
serializer.data, status=status.HTTP_201_CREATED
@@ -265,7 +280,9 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
try:
workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(pk=pk)
-
+ current_instance = json.dumps(
+ ProjectSerializer(project).data, cls=DjangoJSONEncoder
+ )
if project.archived_at:
return Response(
{"error": "Archived project cannot be updated"},
@@ -303,6 +320,17 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
.filter(pk=serializer.data["id"])
.first()
)
+
+ model_activity.delay(
+ model_name="project",
+ model_id=str(project.id),
+ requested_data=request.data,
+ current_instance=current_instance,
+ actor_id=request.user.id,
+ slug=slug,
+ origin=request.META.get("HTTP_ORIGIN"),
+ )
+
serializer = ProjectSerializer(project)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(
diff --git a/apiserver/plane/api/views/state.py b/apiserver/plane/api/views/state.py
index 024a12d07..dd239754c 100644
--- a/apiserver/plane/api/views/state.py
+++ b/apiserver/plane/api/views/state.py
@@ -138,7 +138,7 @@ class StateAPIEndpoint(BaseAPIView):
serializer = StateSerializer(state, data=request.data, partial=True)
if serializer.is_valid():
if (
- str(request.data.get("external_id"))
+ request.data.get("external_id")
and (state.external_id != str(request.data.get("external_id")))
and State.objects.filter(
project_id=project_id,
diff --git a/apiserver/plane/app/serializers/__init__.py b/apiserver/plane/app/serializers/__init__.py
index cd0fc11ce..bdcdf6c0d 100644
--- a/apiserver/plane/app/serializers/__init__.py
+++ b/apiserver/plane/app/serializers/__init__.py
@@ -7,6 +7,8 @@ from .user import (
UserAdminLiteSerializer,
UserMeSerializer,
UserMeSettingsSerializer,
+ ProfileSerializer,
+ AccountSerializer,
)
from .workspace import (
WorkSpaceSerializer,
@@ -26,7 +28,6 @@ from .project import (
ProjectMemberSerializer,
ProjectMemberInviteSerializer,
ProjectIdentifierSerializer,
- ProjectFavoriteSerializer,
ProjectLiteSerializer,
ProjectMemberLiteSerializer,
ProjectDeployBoardSerializer,
@@ -38,12 +39,10 @@ from .state import StateSerializer, StateLiteSerializer
from .view import (
GlobalViewSerializer,
IssueViewSerializer,
- IssueViewFavoriteSerializer,
)
from .cycle import (
CycleSerializer,
CycleIssueSerializer,
- CycleFavoriteSerializer,
CycleWriteSerializer,
CycleUserPropertiesSerializer,
)
@@ -81,7 +80,6 @@ from .module import (
ModuleSerializer,
ModuleIssueSerializer,
ModuleLinkSerializer,
- ModuleFavoriteSerializer,
ModuleUserPropertiesSerializer,
)
@@ -94,7 +92,6 @@ from .page import (
PageLogSerializer,
SubPageSerializer,
PageDetailSerializer,
- PageFavoriteSerializer,
)
from .estimate import (
diff --git a/apiserver/plane/app/serializers/cycle.py b/apiserver/plane/app/serializers/cycle.py
index 13d321780..1a9ce52d1 100644
--- a/apiserver/plane/app/serializers/cycle.py
+++ b/apiserver/plane/app/serializers/cycle.py
@@ -7,7 +7,6 @@ from .issue import IssueStateSerializer
from plane.db.models import (
Cycle,
CycleIssue,
- CycleFavorite,
CycleUserProperties,
)
@@ -93,20 +92,6 @@ class CycleIssueSerializer(BaseSerializer):
"cycle",
]
-
-class CycleFavoriteSerializer(BaseSerializer):
- cycle_detail = CycleSerializer(source="cycle", read_only=True)
-
- class Meta:
- model = CycleFavorite
- fields = "__all__"
- read_only_fields = [
- "workspace",
- "project",
- "user",
- ]
-
-
class CycleUserPropertiesSerializer(BaseSerializer):
class Meta:
model = CycleUserProperties
diff --git a/apiserver/plane/app/serializers/issue.py b/apiserver/plane/app/serializers/issue.py
index 8c641b720..e4a04fadf 100644
--- a/apiserver/plane/app/serializers/issue.py
+++ b/apiserver/plane/app/serializers/issue.py
@@ -442,7 +442,7 @@ class IssueLinkSerializer(BaseSerializer):
raise serializers.ValidationError("Invalid URL format.")
# Check URL scheme
- if not value.startswith(('http://', 'https://')):
+ if not value.startswith(("http://", "https://")):
raise serializers.ValidationError("Invalid URL scheme.")
return value
@@ -462,7 +462,7 @@ class IssueLinkSerializer(BaseSerializer):
if IssueLink.objects.filter(
url=validated_data.get("url"),
issue_id=instance.issue_id,
- ).exists():
+ ).exclude(pk=instance.id).exists():
raise serializers.ValidationError(
{"error": "URL already exists for this Issue"}
)
@@ -636,6 +636,7 @@ class IssueInboxSerializer(DynamicBaseSerializer):
"project_id",
"created_at",
"label_ids",
+ "created_by",
]
read_only_fields = fields
diff --git a/apiserver/plane/app/serializers/module.py b/apiserver/plane/app/serializers/module.py
index 687747242..6a0c4c94f 100644
--- a/apiserver/plane/app/serializers/module.py
+++ b/apiserver/plane/app/serializers/module.py
@@ -11,7 +11,6 @@ from plane.db.models import (
ModuleMember,
ModuleIssue,
ModuleLink,
- ModuleFavorite,
ModuleUserProperties,
)
@@ -223,19 +222,6 @@ class ModuleDetailSerializer(ModuleSerializer):
fields = ModuleSerializer.Meta.fields + ["link_module", "sub_issues"]
-class ModuleFavoriteSerializer(BaseSerializer):
- module_detail = ModuleFlatSerializer(source="module", read_only=True)
-
- class Meta:
- model = ModuleFavorite
- fields = "__all__"
- read_only_fields = [
- "workspace",
- "project",
- "user",
- ]
-
-
class ModuleUserPropertiesSerializer(BaseSerializer):
class Meta:
model = ModuleUserProperties
diff --git a/apiserver/plane/app/serializers/page.py b/apiserver/plane/app/serializers/page.py
index 604ac2c2e..4f3cde39b 100644
--- a/apiserver/plane/app/serializers/page.py
+++ b/apiserver/plane/app/serializers/page.py
@@ -6,7 +6,6 @@ from .base import BaseSerializer
from plane.db.models import (
Page,
PageLog,
- PageFavorite,
PageLabel,
Label,
)
@@ -141,17 +140,4 @@ class PageLogSerializer(BaseSerializer):
"workspace",
"project",
"page",
- ]
-
-
-class PageFavoriteSerializer(BaseSerializer):
- page_detail = PageSerializer(source="page", read_only=True)
-
- class Meta:
- model = PageFavorite
- fields = "__all__"
- read_only_fields = [
- "workspace",
- "project",
- "user",
- ]
+ ]
\ No newline at end of file
diff --git a/apiserver/plane/app/serializers/project.py b/apiserver/plane/app/serializers/project.py
index a0c2318e3..96d92f340 100644
--- a/apiserver/plane/app/serializers/project.py
+++ b/apiserver/plane/app/serializers/project.py
@@ -13,7 +13,6 @@ from plane.db.models import (
ProjectMember,
ProjectMemberInvite,
ProjectIdentifier,
- ProjectFavorite,
ProjectDeployBoard,
ProjectPublicMember,
)
@@ -197,16 +196,6 @@ class ProjectIdentifierSerializer(BaseSerializer):
fields = "__all__"
-class ProjectFavoriteSerializer(BaseSerializer):
- class Meta:
- model = ProjectFavorite
- fields = "__all__"
- read_only_fields = [
- "workspace",
- "user",
- ]
-
-
class ProjectMemberLiteSerializer(BaseSerializer):
member = UserLiteSerializer(read_only=True)
is_subscribed = serializers.BooleanField(read_only=True)
diff --git a/apiserver/plane/app/serializers/user.py b/apiserver/plane/app/serializers/user.py
index d6c15ee7f..05d8665b5 100644
--- a/apiserver/plane/app/serializers/user.py
+++ b/apiserver/plane/app/serializers/user.py
@@ -2,8 +2,15 @@
from rest_framework import serializers
# Module import
+from plane.db.models import (
+ Account,
+ Profile,
+ User,
+ Workspace,
+ WorkspaceMemberInvite,
+)
+
from .base import BaseSerializer
-from plane.db.models import User, Workspace, WorkspaceMemberInvite
class UserSerializer(BaseSerializer):
@@ -23,10 +30,10 @@ class UserSerializer(BaseSerializer):
"last_logout_ip",
"last_login_uagent",
"token_updated_at",
- "is_onboarded",
"is_bot",
"is_password_autoset",
"is_email_verified",
+ "is_active",
]
extra_kwargs = {"password": {"write_only": True}}
@@ -50,19 +57,11 @@ class UserMeSerializer(BaseSerializer):
"is_active",
"is_bot",
"is_email_verified",
- "is_managed",
- "is_onboarded",
- "is_tour_completed",
- "mobile_number",
- "role",
- "onboarding_step",
"user_timezone",
"username",
- "theme",
- "last_workspace_id",
- "use_case",
"is_password_autoset",
"is_email_verified",
+ "last_login_medium",
]
read_only_fields = fields
@@ -83,25 +82,28 @@ class UserMeSettingsSerializer(BaseSerializer):
workspace_invites = WorkspaceMemberInvite.objects.filter(
email=obj.email
).count()
+
+ # profile
+ profile = Profile.objects.get(user=obj)
if (
- obj.last_workspace_id is not None
+ profile.last_workspace_id is not None
and Workspace.objects.filter(
- pk=obj.last_workspace_id,
+ pk=profile.last_workspace_id,
workspace_member__member=obj.id,
workspace_member__is_active=True,
).exists()
):
workspace = Workspace.objects.filter(
- pk=obj.last_workspace_id,
+ pk=profile.last_workspace_id,
workspace_member__member=obj.id,
workspace_member__is_active=True,
).first()
return {
- "last_workspace_id": obj.last_workspace_id,
+ "last_workspace_id": profile.last_workspace_id,
"last_workspace_slug": (
workspace.slug if workspace is not None else ""
),
- "fallback_workspace_id": obj.last_workspace_id,
+ "fallback_workspace_id": profile.last_workspace_id,
"fallback_workspace_slug": (
workspace.slug if workspace is not None else ""
),
@@ -200,3 +202,15 @@ class ResetPasswordSerializer(serializers.Serializer):
"""
new_password = serializers.CharField(required=True, min_length=8)
+
+
+class ProfileSerializer(BaseSerializer):
+ class Meta:
+ model = Profile
+ fields = "__all__"
+
+
+class AccountSerializer(BaseSerializer):
+ class Meta:
+ model = Account
+ fields = "__all__"
diff --git a/apiserver/plane/app/serializers/view.py b/apiserver/plane/app/serializers/view.py
index f864f2b6c..c46a545d0 100644
--- a/apiserver/plane/app/serializers/view.py
+++ b/apiserver/plane/app/serializers/view.py
@@ -5,7 +5,7 @@ from rest_framework import serializers
from .base import BaseSerializer, DynamicBaseSerializer
from .workspace import WorkspaceLiteSerializer
from .project import ProjectLiteSerializer
-from plane.db.models import GlobalView, IssueView, IssueViewFavorite
+from plane.db.models import GlobalView, IssueView
from plane.utils.issue_filters import issue_filters
@@ -72,16 +72,3 @@ class IssueViewSerializer(DynamicBaseSerializer):
validated_data["query"] = {}
validated_data["query"] = issue_filters(query_params, "PATCH")
return super().update(instance, validated_data)
-
-
-class IssueViewFavoriteSerializer(BaseSerializer):
- view_detail = IssueViewSerializer(source="issue_view", read_only=True)
-
- class Meta:
- model = IssueViewFavorite
- fields = "__all__"
- read_only_fields = [
- "workspace",
- "project",
- "user",
- ]
diff --git a/apiserver/plane/app/urls/__init__.py b/apiserver/plane/app/urls/__init__.py
index 40b96687d..cb5f0253a 100644
--- a/apiserver/plane/app/urls/__init__.py
+++ b/apiserver/plane/app/urls/__init__.py
@@ -1,7 +1,6 @@
from .analytic import urlpatterns as analytic_urls
+from .api import urlpatterns as api_urls
from .asset import urlpatterns as asset_urls
-from .authentication import urlpatterns as authentication_urls
-from .config import urlpatterns as configuration_urls
from .cycle import urlpatterns as cycle_urls
from .dashboard import urlpatterns as dashboard_urls
from .estimate import urlpatterns as estimate_urls
@@ -16,16 +15,12 @@ from .search import urlpatterns as search_urls
from .state import urlpatterns as state_urls
from .user import urlpatterns as user_urls
from .views import urlpatterns as view_urls
-from .workspace import urlpatterns as workspace_urls
-from .api import urlpatterns as api_urls
from .webhook import urlpatterns as webhook_urls
-
+from .workspace import urlpatterns as workspace_urls
urlpatterns = [
*analytic_urls,
*asset_urls,
- *authentication_urls,
- *configuration_urls,
*cycle_urls,
*dashboard_urls,
*estimate_urls,
diff --git a/apiserver/plane/app/urls/authentication.py b/apiserver/plane/app/urls/authentication.py
deleted file mode 100644
index e91e5706b..000000000
--- a/apiserver/plane/app/urls/authentication.py
+++ /dev/null
@@ -1,65 +0,0 @@
-from django.urls import path
-
-from rest_framework_simplejwt.views import TokenRefreshView
-
-
-from plane.app.views import (
- # Authentication
- SignInEndpoint,
- SignOutEndpoint,
- MagicGenerateEndpoint,
- MagicSignInEndpoint,
- OauthEndpoint,
- EmailCheckEndpoint,
- ## End Authentication
- # Auth Extended
- ForgotPasswordEndpoint,
- ResetPasswordEndpoint,
- ChangePasswordEndpoint,
- ## End Auth Extender
- # API Tokens
- ApiTokenEndpoint,
- ## End API Tokens
-)
-
-
-urlpatterns = [
- # Social Auth
- path("email-check/", EmailCheckEndpoint.as_view(), name="email"),
- path("social-auth/", OauthEndpoint.as_view(), name="oauth"),
- # Auth
- 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
- path(
- "users/me/change-password/",
- ChangePasswordEndpoint.as_view(),
- name="change-password",
- ),
- path(
- "reset-password///",
- ResetPasswordEndpoint.as_view(),
- name="password-reset",
- ),
- path(
- "forgot-password/",
- ForgotPasswordEndpoint.as_view(),
- name="forgot-password",
- ),
- # API Tokens
- path("api-tokens/", ApiTokenEndpoint.as_view(), name="api-tokens"),
- path(
- "api-tokens//", ApiTokenEndpoint.as_view(), name="api-tokens"
- ),
- ## End API Tokens
-]
diff --git a/apiserver/plane/app/urls/config.py b/apiserver/plane/app/urls/config.py
deleted file mode 100644
index 3ea825eb2..000000000
--- a/apiserver/plane/app/urls/config.py
+++ /dev/null
@@ -1,17 +0,0 @@
-from django.urls import path
-
-
-from plane.app.views import ConfigurationEndpoint, MobileConfigurationEndpoint
-
-urlpatterns = [
- path(
- "configs/",
- ConfigurationEndpoint.as_view(),
- name="configuration",
- ),
- path(
- "mobile-configs/",
- MobileConfigurationEndpoint.as_view(),
- name="configuration",
- ),
-]
diff --git a/apiserver/plane/app/urls/user.py b/apiserver/plane/app/urls/user.py
index 9dae7b5da..fd18ea87b 100644
--- a/apiserver/plane/app/urls/user.py
+++ b/apiserver/plane/app/urls/user.py
@@ -1,20 +1,20 @@
from django.urls import path
from plane.app.views import (
- ## User
- UserEndpoint,
+ AccountEndpoint,
+ ProfileEndpoint,
UpdateUserOnBoardedEndpoint,
UpdateUserTourCompletedEndpoint,
UserActivityEndpoint,
- ChangePasswordEndpoint,
- SetUserPasswordEndpoint,
+ UserActivityGraphEndpoint,
+ ## User
+ UserEndpoint,
+ UserIssueCompletedGraphEndpoint,
+ UserWorkspaceDashboardEndpoint,
+ UserSessionEndpoint,
## End User
## Workspaces
UserWorkSpacesEndpoint,
- UserActivityGraphEndpoint,
- UserIssueCompletedGraphEndpoint,
- UserWorkspaceDashboardEndpoint,
- ## End Workspaces
)
urlpatterns = [
@@ -30,6 +30,11 @@ urlpatterns = [
),
name="users",
),
+ path(
+ "users/session/",
+ UserSessionEndpoint.as_view(),
+ name="user-session",
+ ),
path(
"users/me/settings/",
UserEndpoint.as_view(
@@ -39,6 +44,25 @@ urlpatterns = [
),
name="users",
),
+ # Profile
+ path(
+ "users/me/profile/",
+ ProfileEndpoint.as_view(),
+ name="accounts",
+ ),
+ # End profile
+ # Accounts
+ path(
+ "users/me/accounts/",
+ AccountEndpoint.as_view(),
+ name="accounts",
+ ),
+ path(
+ "users/me/accounts//",
+ AccountEndpoint.as_view(),
+ name="accounts",
+ ),
+ ## End Accounts
path(
"users/me/instance-admin/",
UserEndpoint.as_view(
@@ -48,11 +72,6 @@ urlpatterns = [
),
name="users",
),
- path(
- "users/me/change-password/",
- ChangePasswordEndpoint.as_view(),
- name="change-password",
- ),
path(
"users/me/onboard/",
UpdateUserOnBoardedEndpoint.as_view(),
@@ -90,10 +109,5 @@ urlpatterns = [
UserWorkspaceDashboardEndpoint.as_view(),
name="user-workspace-dashboard",
),
- path(
- "users/me/set-password/",
- SetUserPasswordEndpoint.as_view(),
- name="set-password",
- ),
## End User Graph
]
diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py
index 3d7603e24..bf765e719 100644
--- a/apiserver/plane/app/views/__init__.py
+++ b/apiserver/plane/app/views/__init__.py
@@ -28,9 +28,8 @@ from .user.base import (
UserActivityEndpoint,
)
-from .oauth import OauthEndpoint
-from .base import BaseAPIView, BaseViewSet, WebhookMixin
+from .base import BaseAPIView, BaseViewSet
from .workspace.base import (
WorkSpaceViewSet,
@@ -92,6 +91,8 @@ from .cycle.base import (
CycleFavoriteViewSet,
TransferCycleIssueEndpoint,
CycleUserPropertiesEndpoint,
+ CycleViewSet,
+ TransferCycleIssueEndpoint,
)
from .cycle.issue import (
CycleIssueViewSet,
@@ -152,21 +153,6 @@ from .issue.subscriber import (
IssueSubscriberViewSet,
)
-from .auth_extended import (
- ForgotPasswordEndpoint,
- ResetPasswordEndpoint,
- ChangePasswordEndpoint,
- SetUserPasswordEndpoint,
- EmailCheckEndpoint,
- MagicGenerateEndpoint,
-)
-
-
-from .authentication import (
- SignInEndpoint,
- SignOutEndpoint,
- MagicSignInEndpoint,
-)
from .module.base import (
ModuleViewSet,
@@ -200,7 +186,6 @@ from .external.base import (
GPTIntegrationEndpoint,
UnsplashEndpoint,
)
-
from .estimate.base import (
ProjectEstimatePointEndpoint,
BulkEstimatePointEndpoint,
@@ -219,13 +204,11 @@ from .analytic.base import (
from .notification.base import (
NotificationViewSet,
UnreadNotificationEndpoint,
- MarkAllReadNotificationViewSet,
UserNotificationPreferenceEndpoint,
)
from .exporter.base import ExportIssuesEndpoint
-from .config import ConfigurationEndpoint, MobileConfigurationEndpoint
from .webhook.base import (
WebhookEndpoint,
@@ -236,3 +219,7 @@ from .webhook.base import (
from .dashboard.base import DashboardEndpoint, WidgetsEndpoint
from .error_404 import custom_404_view
+
+from .exporter.base import ExportIssuesEndpoint
+from .notification.base import MarkAllReadNotificationViewSet
+from .user.base import AccountEndpoint, ProfileEndpoint, UserSessionEndpoint
diff --git a/apiserver/plane/app/views/analytic/base.py b/apiserver/plane/app/views/analytic/base.py
index 8e0d3220d..256d3cae5 100644
--- a/apiserver/plane/app/views/analytic/base.py
+++ b/apiserver/plane/app/views/analytic/base.py
@@ -1,5 +1,5 @@
# Django imports
-from django.db.models import Count, Sum, F
+from django.db.models import Count, F, Sum
from django.db.models.functions import ExtractMonth
from django.utils import timezone
@@ -7,13 +7,14 @@ from django.utils import timezone
from rest_framework import status
from rest_framework.response import Response
-# Module imports
-from plane.app.views import BaseAPIView, BaseViewSet
from plane.app.permissions import WorkSpaceAdminPermission
-from plane.db.models import Issue, AnalyticView, Workspace
from plane.app.serializers import AnalyticViewSerializer
-from plane.utils.analytics_plot import build_graph_plot
+
+# Module imports
+from plane.app.views.base import BaseAPIView, BaseViewSet
from plane.bgtasks.analytic_plot_export import analytic_export_task
+from plane.db.models import AnalyticView, Issue, Workspace
+from plane.utils.analytics_plot import build_graph_plot
from plane.utils.issue_filters import issue_filters
diff --git a/apiserver/plane/app/views/auth_extended.py b/apiserver/plane/app/views/auth_extended.py
deleted file mode 100644
index 896f4170f..000000000
--- a/apiserver/plane/app/views/auth_extended.py
+++ /dev/null
@@ -1,482 +0,0 @@
-## Python imports
-import uuid
-import os
-import json
-import random
-import string
-
-## Django imports
-from django.contrib.auth.tokens import PasswordResetTokenGenerator
-from django.utils.encoding import (
- smart_str,
- smart_bytes,
- DjangoUnicodeDecodeError,
-)
-from django.contrib.auth.hashers import make_password
-from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
-from django.core.validators import validate_email
-from django.core.exceptions import ValidationError
-
-## Third Party Imports
-from rest_framework import status
-from rest_framework.response import Response
-from rest_framework.permissions import AllowAny
-from rest_framework_simplejwt.tokens import RefreshToken
-
-## Module imports
-from . import BaseAPIView
-from plane.app.serializers import (
- ChangePasswordSerializer,
- ResetPasswordSerializer,
- UserSerializer,
-)
-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
-from plane.settings.redis import redis_instance
-from plane.bgtasks.magic_link_code_task import magic_link
-from plane.bgtasks.event_tracking_task import auth_events
-
-
-def get_tokens_for_user(user):
- refresh = RefreshToken.for_user(user)
- return (
- str(refresh.access_token),
- str(refresh),
- )
-
-
-def generate_magic_token(email):
- key = "magic_" + str(email)
-
- ## 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))
- )
-
- # Initialize the redis instance
- ri = redis_instance()
-
- # 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 key, token, False
-
- 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)
-
- return key, token, True
-
-
-def generate_password_token(user):
- uidb64 = urlsafe_base64_encode(smart_bytes(user.id))
- token = PasswordResetTokenGenerator().make_token(user)
-
- return uidb64, token
-
-
-class ForgotPasswordEndpoint(BaseAPIView):
- permission_classes = [
- AllowAny,
- ]
-
- def post(self, request):
- email = request.data.get("email")
-
- try:
- validate_email(email)
- except ValidationError:
- return Response(
- {"error": "Please enter a valid email"},
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- # Get the user
- user = User.objects.filter(email=email).first()
- if user:
- # Get the reset token for user
- uidb64, token = generate_password_token(user=user)
- current_site = request.META.get("HTTP_ORIGIN")
- # send the forgot password email
- forgot_password.delay(
- user.first_name, user.email, uidb64, token, current_site
- )
- return Response(
- {"message": "Check your email to reset your password"},
- status=status.HTTP_200_OK,
- )
- return Response(
- {"error": "Please check the email"},
- status=status.HTTP_400_BAD_REQUEST,
- )
-
-
-class ResetPasswordEndpoint(BaseAPIView):
- permission_classes = [
- AllowAny,
- ]
-
- def post(self, request, uidb64, token):
- try:
- # Decode the id from the uidb64
- id = smart_str(urlsafe_base64_decode(uidb64))
- user = User.objects.get(id=id)
-
- # check if the token is valid for the user
- if not PasswordResetTokenGenerator().check_token(user, token):
- return Response(
- {"error": "Token is invalid"},
- status=status.HTTP_401_UNAUTHORIZED,
- )
-
- # Reset the password
- serializer = ResetPasswordSerializer(data=request.data)
- if serializer.is_valid():
- # set_password also hashes the password that the user will get
- user.set_password(serializer.data.get("new_password"))
- user.is_password_autoset = False
- user.save()
-
- # Log the user in
- # Generate access token for the user
- access_token, refresh_token = get_tokens_for_user(user)
- data = {
- "access_token": access_token,
- "refresh_token": refresh_token,
- }
-
- return Response(data, status=status.HTTP_200_OK)
- return Response(
- serializer.errors, status=status.HTTP_400_BAD_REQUEST
- )
-
- except DjangoUnicodeDecodeError:
- return Response(
- {"error": "token is not valid, please check the new one"},
- status=status.HTTP_401_UNAUTHORIZED,
- )
-
-
-class ChangePasswordEndpoint(BaseAPIView):
- def post(self, request):
- serializer = ChangePasswordSerializer(data=request.data)
- user = User.objects.get(pk=request.user.id)
- if serializer.is_valid():
- if not user.check_password(serializer.data.get("old_password")):
- return Response(
- {"error": "Old password is not correct"},
- status=status.HTTP_400_BAD_REQUEST,
- )
- # set_password also hashes the password that the user will get
- user.set_password(serializer.data.get("new_password"))
- user.is_password_autoset = False
- user.save()
- return Response(
- {"message": "Password updated successfully"},
- status=status.HTTP_200_OK,
- )
- return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
-
-
-class SetUserPasswordEndpoint(BaseAPIView):
- def post(self, request):
- user = User.objects.get(pk=request.user.id)
- password = request.data.get("password", False)
-
- # If the user password is not autoset then return error
- if not user.is_password_autoset:
- return Response(
- {
- "error": "Your password is already set please change your password from profile"
- },
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- # Check password validation
- if not password and len(str(password)) < 8:
- return Response(
- {"error": "Password is not valid"},
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- # Set the user password
- user.set_password(password)
- user.is_password_autoset = False
- user.save()
- serializer = UserSerializer(user)
- return Response(serializer.data, status=status.HTTP_200_OK)
-
-
-class MagicGenerateEndpoint(BaseAPIView):
- permission_classes = [
- AllowAny,
- ]
-
- def post(self, request):
- email = request.data.get("email", False)
-
- # Check the instance registration
- instance = Instance.objects.first()
- if instance is None or not instance.is_setup_done:
- return Response(
- {"error": "Instance is not configured"},
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- if not email:
- return Response(
- {"error": "Please provide a valid email address"},
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- # Clean up the email
- email = email.strip().lower()
- validate_email(email)
-
- # check if the email exists not
- if not User.objects.filter(email=email).exists():
- # Create a user
- _ = User.objects.create(
- email=email,
- username=uuid.uuid4().hex,
- password=make_password(uuid.uuid4().hex),
- is_password_autoset=True,
- )
-
- ## Generate a random token
- token = (
- "".join(random.choices(string.ascii_lowercase, k=4))
- + "-"
- + "".join(random.choices(string.ascii_lowercase, k=4))
- + "-"
- + "".join(random.choices(string.ascii_lowercase, k=4))
- )
-
- ri = redis_instance()
-
- key = "magic_" + str(email)
-
- # Check if the key already exists in python
- if ri.exists(key):
- data = json.loads(ri.get(key))
-
- current_attempt = data["current_attempt"] + 1
-
- if data["current_attempt"] > 2:
- return Response(
- {
- "error": "Max attempts exhausted. Please try again later."
- },
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- value = {
- "current_attempt": current_attempt,
- "email": email,
- "token": token,
- }
- expiry = 600
-
- ri.set(key, json.dumps(value), ex=expiry)
-
- else:
- value = {"current_attempt": 0, "email": email, "token": token}
- expiry = 600
-
- ri.set(key, json.dumps(value), ex=expiry)
-
- # If the smtp is configured send through here
- current_site = request.META.get("HTTP_ORIGIN")
- magic_link.delay(email, key, token, current_site)
-
- return Response({"key": key}, status=status.HTTP_200_OK)
-
-
-class EmailCheckEndpoint(BaseAPIView):
- permission_classes = [
- AllowAny,
- ]
-
- def post(self, request):
- # 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,
- )
-
- # 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)
-
- if not email:
- 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,
- )
-
- # Check if the user exists
- user = User.objects.filter(email=email).first()
- current_site = request.META.get("HTTP_ORIGIN")
-
- # If new user
- if user is None:
- # 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,
- )
-
- # Create the user with default values
- user = User.objects.create(
- email=email,
- username=uuid.uuid4().hex,
- password=make_password(uuid.uuid4().hex),
- is_password_autoset=True,
- )
-
- if not bool(
- ENABLE_MAGIC_LINK_LOGIN,
- ):
- return Response(
- {"error": "Magic link sign in is disabled."},
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- # 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 up",
- medium="Magic link",
- first_time=True,
- )
- key, token, current_attempt = generate_magic_token(email=email)
- if not current_attempt:
- return Response(
- {
- "error": "Max attempts exhausted. Please try again later."
- },
- status=status.HTTP_400_BAD_REQUEST,
- )
- # Trigger the email
- magic_link.delay(email, "magic_" + str(email), token, current_site)
- return Response(
- {
- "is_password_autoset": user.is_password_autoset,
- "is_existing": False,
- },
- status=status.HTTP_200_OK,
- )
-
- # Existing user
- else:
- if user.is_password_autoset:
- ## Generate a random token
- if not bool(ENABLE_MAGIC_LINK_LOGIN):
- return Response(
- {"error": "Magic link sign in is disabled."},
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- 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,
- )
-
- # Trigger the email
- magic_link.delay(email, key, token, current_site)
- return Response(
- {
- "is_password_autoset": user.is_password_autoset,
- "is_existing": True,
- },
- status=status.HTTP_200_OK,
- )
- else:
- 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,
- )
diff --git a/apiserver/plane/app/views/authentication.py b/apiserver/plane/app/views/authentication.py
deleted file mode 100644
index 7d898f971..000000000
--- a/apiserver/plane/app/views/authentication.py
+++ /dev/null
@@ -1,453 +0,0 @@
-# Python imports
-import os
-import uuid
-import json
-
-# Django imports
-from django.utils import timezone
-from django.core.exceptions import ValidationError
-from django.core.validators import validate_email
-from django.contrib.auth.hashers import make_password
-
-# Third party imports
-from rest_framework.response import Response
-from rest_framework.permissions import AllowAny
-from rest_framework import status
-from rest_framework_simplejwt.tokens import RefreshToken
-from sentry_sdk import capture_message
-
-# Module imports
-from . import BaseAPIView
-from plane.db.models import (
- User,
- WorkspaceMemberInvite,
- WorkspaceMember,
- ProjectMemberInvite,
- ProjectMember,
-)
-from plane.settings.redis import redis_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
-
-
-def get_tokens_for_user(user):
- refresh = RefreshToken.for_user(user)
- return (
- str(refresh.access_token),
- str(refresh),
- )
-
-
-class SignUpEndpoint(BaseAPIView):
- permission_classes = (AllowAny,)
-
- def post(self, request):
- # Check if the instance configuration is done
- instance = Instance.objects.first()
- if instance is None or not instance.is_setup_done:
- return Response(
- {"error": "Instance is not configured"},
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- 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(
- {"error": "Both email and password are required"},
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- # Validate the email
- email = email.strip().lower()
- try:
- validate_email(email)
- except ValidationError:
- return Response(
- {"error": "Please provide a valid email address."},
- 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 (
- 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,
- )
-
- # Check if the user already exists
- if User.objects.filter(email=email).exists():
- return Response(
- {"error": "User with this email already exists"},
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- user = User.objects.create(email=email, username=uuid.uuid4().hex)
- 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")
- 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)
-
-
-class SignInEndpoint(BaseAPIView):
- permission_classes = (AllowAny,)
-
- def post(self, request):
- # Check if the instance configuration is done
- instance = Instance.objects.first()
- if instance is None or not instance.is_setup_done:
- return Response(
- {"error": "Instance is not configured"},
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- email = request.data.get("email", False)
- password = request.data.get("password", False)
-
- ## Raise exception if any of the above are missing
- if not email or not password:
- return Response(
- {"error": "Both email and password are required"},
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- # Validate email
- email = email.strip().lower()
- try:
- validate_email(email)
- except ValidationError:
- return Response(
- {"error": "Please provide a valid email address."},
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- # Get the user
- user = User.objects.filter(email=email).first()
-
- # Existing user
- if user:
- # Check user password
- if not user.check_password(password):
- return Response(
- {
- "error": "Sorry, we could not find a user with the provided credentials. Please try again."
- },
- status=status.HTTP_403_FORBIDDEN,
- )
-
- # 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
- 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()
-
- # Check if user has any accepted invites for workspace and add them to workspace
- workspace_member_invites = WorkspaceMemberInvite.objects.filter(
- email=user.email, accepted=True
- )
-
- WorkspaceMember.objects.bulk_create(
- [
- WorkspaceMember(
- workspace_id=workspace_member_invite.workspace_id,
- member=user,
- role=workspace_member_invite.role,
- )
- for workspace_member_invite in workspace_member_invites
- ],
- ignore_conflicts=True,
- )
-
- # Check if user has any project invites
- project_member_invites = ProjectMemberInvite.objects.filter(
- email=user.email, accepted=True
- )
-
- # Add user to workspace
- WorkspaceMember.objects.bulk_create(
- [
- WorkspaceMember(
- workspace_id=project_member_invite.workspace_id,
- role=(
- project_member_invite.role
- if project_member_invite.role in [5, 10, 15]
- else 15
- ),
- member=user,
- created_by_id=project_member_invite.created_by_id,
- )
- for project_member_invite in project_member_invites
- ],
- ignore_conflicts=True,
- )
-
- # Now add the users to project
- ProjectMember.objects.bulk_create(
- [
- ProjectMember(
- workspace_id=project_member_invite.workspace_id,
- role=(
- project_member_invite.role
- if project_member_invite.role in [5, 10, 15]
- else 15
- ),
- member=user,
- created_by_id=project_member_invite.created_by_id,
- )
- for project_member_invite in project_member_invites
- ],
- ignore_conflicts=True,
- )
-
- # Delete all the invites
- workspace_member_invites.delete()
- project_member_invites.delete()
- # 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="Email",
- first_time=False,
- )
-
- 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 SignOutEndpoint(BaseAPIView):
- def post(self, request):
- refresh_token = request.data.get("refresh_token", False)
-
- if not refresh_token:
- capture_message("No refresh token provided")
- return Response(
- {"error": "No refresh token provided"},
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- user = User.objects.get(pk=request.user.id)
-
- user.last_logout_time = timezone.now()
- user.last_logout_ip = request.META.get("REMOTE_ADDR")
-
- user.save()
-
- token = RefreshToken(refresh_token)
- token.blacklist()
- return Response({"message": "success"}, status=status.HTTP_200_OK)
-
-
-class MagicSignInEndpoint(BaseAPIView):
- permission_classes = [
- AllowAny,
- ]
-
- def post(self, request):
- # Check if the instance configuration is done
- instance = Instance.objects.first()
- if instance is None or not instance.is_setup_done:
- return Response(
- {"error": "Instance is not configured"},
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- 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,
- )
-
- 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):
- user = User.objects.get(email=email)
- # 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=False,
- )
-
- 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()
-
- # Check if user has any accepted invites for workspace and add them to workspace
- workspace_member_invites = (
- WorkspaceMemberInvite.objects.filter(
- email=user.email, accepted=True
- )
- )
-
- WorkspaceMember.objects.bulk_create(
- [
- WorkspaceMember(
- workspace_id=workspace_member_invite.workspace_id,
- member=user,
- role=workspace_member_invite.role,
- )
- for workspace_member_invite in workspace_member_invites
- ],
- ignore_conflicts=True,
- )
-
- # Check if user has any project invites
- project_member_invites = ProjectMemberInvite.objects.filter(
- email=user.email, accepted=True
- )
-
- # Add user to workspace
- WorkspaceMember.objects.bulk_create(
- [
- WorkspaceMember(
- workspace_id=project_member_invite.workspace_id,
- role=(
- project_member_invite.role
- if project_member_invite.role in [5, 10, 15]
- else 15
- ),
- member=user,
- created_by_id=project_member_invite.created_by_id,
- )
- for project_member_invite in project_member_invites
- ],
- ignore_conflicts=True,
- )
-
- # Now add the users to project
- ProjectMember.objects.bulk_create(
- [
- ProjectMember(
- workspace_id=project_member_invite.workspace_id,
- role=(
- project_member_invite.role
- if project_member_invite.role in [5, 10, 15]
- else 15
- ),
- member=user,
- created_by_id=project_member_invite.created_by_id,
- )
- for project_member_invite in project_member_invites
- ],
- ignore_conflicts=True,
- )
-
- # Delete all the invites
- workspace_member_invites.delete()
- project_member_invites.delete()
-
- 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,
- )
diff --git a/apiserver/plane/app/views/base.py b/apiserver/plane/app/views/base.py
index 1908cfdc9..8f21f5fe1 100644
--- a/apiserver/plane/app/views/base.py
+++ b/apiserver/plane/app/views/base.py
@@ -19,7 +19,7 @@ from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet
# Module imports
-from plane.bgtasks.webhook_task import send_webhook
+from plane.authentication.session import BaseSessionAuthentication
from plane.utils.exception_logger import log_exception
from plane.utils.paginator import BasePaginator
@@ -38,35 +38,6 @@ class TimezoneMixin:
timezone.deactivate()
-class WebhookMixin:
- webhook_event = None
- bulk = False
-
- def finalize_response(self, request, response, *args, **kwargs):
- response = super().finalize_response(
- request, response, *args, **kwargs
- )
-
- # Check for the case should webhook be sent
- if (
- self.webhook_event
- and self.request.method in ["POST", "PATCH", "DELETE"]
- and response.status_code in [200, 201, 204]
- ):
- # Push the object to delay
- send_webhook.delay(
- event=self.webhook_event,
- payload=response.data,
- kw=self.kwargs,
- action=self.request.method,
- slug=self.workspace_slug,
- bulk=self.bulk,
- current_site=request.META.get("HTTP_ORIGIN"),
- )
-
- return response
-
-
class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
model = None
@@ -79,6 +50,10 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
SearchFilter,
)
+ authentication_classes = [
+ BaseSessionAuthentication,
+ ]
+
filterset_fields = []
search_fields = []
@@ -191,6 +166,10 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
SearchFilter,
)
+ authentication_classes = [
+ BaseSessionAuthentication,
+ ]
+
filterset_fields = []
search_fields = []
diff --git a/apiserver/plane/app/views/config.py b/apiserver/plane/app/views/config.py
deleted file mode 100644
index 066f606b9..000000000
--- a/apiserver/plane/app/views/config.py
+++ /dev/null
@@ -1,248 +0,0 @@
-# Python imports
-import os
-
-# Django imports
-
-# Third party imports
-from rest_framework.permissions import AllowAny
-from rest_framework import status
-from rest_framework.response import Response
-
-# Module imports
-from .base import BaseAPIView
-from plane.license.utils.instance_value import get_configuration_value
-from plane.utils.cache import cache_response
-
-class ConfigurationEndpoint(BaseAPIView):
- permission_classes = [
- AllowAny,
- ]
-
- @cache_response(60 * 60 * 2, user=False)
- def get(self, request):
- # 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", None),
- },
- {
- "key": "POSTHOG_API_KEY",
- "default": os.environ.get("POSTHOG_API_KEY", None),
- },
- {
- "key": "POSTHOG_HOST",
- "default": os.environ.get("POSTHOG_HOST", None),
- },
- {
- "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"] = (
- GOOGLE_CLIENT_ID
- if GOOGLE_CLIENT_ID and GOOGLE_CLIENT_ID != '""'
- else None
- )
- data["github_client_id"] = (
- GITHUB_CLIENT_ID
- if GITHUB_CLIENT_ID and GITHUB_CLIENT_ID != '""'
- else None
- )
- data["github_app_name"] = GITHUB_APP_NAME
- data["magic_login"] = (
- bool(EMAIL_HOST_USER) and bool(EMAIL_HOST_PASSWORD)
- ) and ENABLE_MAGIC_LINK_LOGIN == "1"
-
- data["email_password_login"] = ENABLE_EMAIL_PASSWORD == "1"
- # Slack client
- data["slack_client_id"] = SLACK_CLIENT_ID
-
- # Posthog
- data["posthog_api_key"] = POSTHOG_API_KEY
- data["posthog_host"] = POSTHOG_HOST
-
- # Unsplash
- data["has_unsplash_configured"] = bool(UNSPLASH_ACCESS_KEY)
-
- # Open AI settings
- data["has_openai_configured"] = bool(OPENAI_API_KEY)
-
- # File size settings
- data["file_size_limit"] = float(
- os.environ.get("FILE_SIZE_LIMIT", 5242880)
- )
-
- # is smtp configured
- data["is_smtp_configured"] = bool(EMAIL_HOST_USER) and bool(
- EMAIL_HOST_PASSWORD
- )
-
- return Response(data, status=status.HTTP_200_OK)
-
-
-class MobileConfigurationEndpoint(BaseAPIView):
- permission_classes = [
- AllowAny,
- ]
-
- @cache_response(60 * 60 * 2, user=False)
- def get(self, request):
- (
- GOOGLE_CLIENT_ID,
- GOOGLE_SERVER_CLIENT_ID,
- GOOGLE_IOS_CLIENT_ID,
- EMAIL_HOST_USER,
- EMAIL_HOST_PASSWORD,
- ENABLE_MAGIC_LINK_LOGIN,
- ENABLE_EMAIL_PASSWORD,
- 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": "GOOGLE_SERVER_CLIENT_ID",
- "default": os.environ.get("GOOGLE_SERVER_CLIENT_ID", None),
- },
- {
- "key": "GOOGLE_IOS_CLIENT_ID",
- "default": os.environ.get("GOOGLE_IOS_CLIENT_ID", 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": "POSTHOG_API_KEY",
- "default": os.environ.get("POSTHOG_API_KEY", None),
- },
- {
- "key": "POSTHOG_HOST",
- "default": os.environ.get("POSTHOG_HOST", None),
- },
- {
- "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"] = (
- GOOGLE_CLIENT_ID
- if GOOGLE_CLIENT_ID and GOOGLE_CLIENT_ID != '""'
- else None
- )
- data["google_server_client_id"] = (
- GOOGLE_SERVER_CLIENT_ID
- if GOOGLE_SERVER_CLIENT_ID and GOOGLE_SERVER_CLIENT_ID != '""'
- else None
- )
- data["google_ios_client_id"] = (
- (GOOGLE_IOS_CLIENT_ID)[::-1]
- if GOOGLE_IOS_CLIENT_ID is not None
- else None
- )
- # Posthog
- data["posthog_api_key"] = POSTHOG_API_KEY
- data["posthog_host"] = POSTHOG_HOST
-
- data["magic_login"] = (
- bool(EMAIL_HOST_USER) and bool(EMAIL_HOST_PASSWORD)
- ) and ENABLE_MAGIC_LINK_LOGIN == "1"
-
- data["email_password_login"] = ENABLE_EMAIL_PASSWORD == "1"
-
- # Posthog
- data["posthog_api_key"] = POSTHOG_API_KEY
- data["posthog_host"] = POSTHOG_HOST
-
- # Unsplash
- data["has_unsplash_configured"] = bool(UNSPLASH_ACCESS_KEY)
-
- # Open AI settings
- data["has_openai_configured"] = bool(OPENAI_API_KEY)
-
- # File size settings
- data["file_size_limit"] = float(
- os.environ.get("FILE_SIZE_LIMIT", 5242880)
- )
-
- # is smtp configured
- data["is_smtp_configured"] = not (
- bool(EMAIL_HOST_USER) and bool(EMAIL_HOST_PASSWORD)
- )
-
- return Response(data, status=status.HTTP_200_OK)
diff --git a/apiserver/plane/app/views/cycle/archive.py b/apiserver/plane/app/views/cycle/archive.py
index e6d82795a..5e1241b08 100644
--- a/apiserver/plane/app/views/cycle/archive.py
+++ b/apiserver/plane/app/views/cycle/archive.py
@@ -24,7 +24,7 @@ from rest_framework.response import Response
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import (
Cycle,
- CycleFavorite,
+ UserFavorite,
Issue,
Label,
User,
@@ -42,9 +42,10 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
]
def get_queryset(self):
- favorite_subquery = CycleFavorite.objects.filter(
+ favorite_subquery = UserFavorite.objects.filter(
user=self.request.user,
- cycle_id=OuterRef("pk"),
+ entity_type="cycle",
+ entity_identifier=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py
index dd9826c56..e0b28ac7b 100644
--- a/apiserver/plane/app/views/cycle/base.py
+++ b/apiserver/plane/app/views/cycle/base.py
@@ -20,6 +20,7 @@ from django.db.models import (
)
from django.db.models.functions import Coalesce
from django.utils import timezone
+from django.core.serializers.json import DjangoJSONEncoder
# Third party imports
from rest_framework import status
@@ -29,7 +30,6 @@ from plane.app.permissions import (
ProjectLitePermission,
)
from plane.app.serializers import (
- CycleFavoriteSerializer,
CycleSerializer,
CycleUserPropertiesSerializer,
CycleWriteSerializer,
@@ -37,8 +37,8 @@ from plane.app.serializers import (
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Cycle,
- CycleFavorite,
CycleIssue,
+ UserFavorite,
CycleUserProperties,
Issue,
Label,
@@ -47,10 +47,11 @@ from plane.db.models import (
from plane.utils.analytics_plot import burndown_plot
# Module imports
-from .. import BaseAPIView, BaseViewSet, WebhookMixin
+from .. import BaseAPIView, BaseViewSet
+from plane.bgtasks.webhook_task import model_activity
-class CycleViewSet(WebhookMixin, BaseViewSet):
+class CycleViewSet(BaseViewSet):
serializer_class = CycleSerializer
model = Cycle
webhook_event = "cycle"
@@ -65,9 +66,10 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
)
def get_queryset(self):
- favorite_subquery = CycleFavorite.objects.filter(
+ favorite_subquery = UserFavorite.objects.filter(
user=self.request.user,
- cycle_id=OuterRef("pk"),
+ entity_identifier=OuterRef("pk"),
+ entity_type="cycle",
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
@@ -239,6 +241,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
"backlog_issues",
"assignee_ids",
"status",
+ "created_by",
)
if data:
@@ -363,6 +366,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
"backlog_issues",
"assignee_ids",
"status",
+ "created_by",
)
return Response(data, status=status.HTTP_200_OK)
@@ -412,6 +416,17 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
)
.first()
)
+
+ # Send the model activity
+ model_activity.delay(
+ model_name="cycle",
+ model_id=str(cycle["id"]),
+ requested_data=request.data,
+ current_instance=None,
+ actor_id=request.user.id,
+ slug=slug,
+ origin=request.META.get("HTTP_ORIGIN"),
+ )
return Response(cycle, status=status.HTTP_201_CREATED)
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
@@ -434,6 +449,11 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
{"error": "Archived cycle cannot be updated"},
status=status.HTTP_400_BAD_REQUEST,
)
+
+ current_instance = json.dumps(
+ CycleSerializer(cycle).data, cls=DjangoJSONEncoder
+ )
+
request_data = request.data
if (
@@ -487,6 +507,18 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
"assignee_ids",
"status",
).first()
+
+ # Send the model activity
+ model_activity.delay(
+ model_name="cycle",
+ model_id=str(cycle["id"]),
+ requested_data=request.data,
+ current_instance=current_instance,
+ actor_id=request.user.id,
+ slug=slug,
+ origin=request.META.get("HTTP_ORIGIN"),
+ )
+
return Response(cycle, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -534,6 +566,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
"backlog_issues",
"assignee_ids",
"status",
+ "created_by",
)
.first()
)
@@ -721,8 +754,7 @@ class CycleDateCheckEndpoint(BaseAPIView):
class CycleFavoriteViewSet(BaseViewSet):
- serializer_class = CycleFavoriteSerializer
- model = CycleFavorite
+ model = UserFavorite
def get_queryset(self):
return self.filter_queryset(
@@ -734,18 +766,21 @@ class CycleFavoriteViewSet(BaseViewSet):
)
def create(self, request, slug, project_id):
- serializer = CycleFavoriteSerializer(data=request.data)
- if serializer.is_valid():
- serializer.save(user=request.user, project_id=project_id)
- return Response(serializer.data, status=status.HTTP_201_CREATED)
- return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+ _ = UserFavorite.objects.create(
+ project_id=project_id,
+ user=request.user,
+ entity_type="cycle",
+ entity_identifier=request.data.get("cycle"),
+ )
+ return Response(status=status.HTTP_204_NO_CONTENT)
def destroy(self, request, slug, project_id, cycle_id):
- cycle_favorite = CycleFavorite.objects.get(
+ cycle_favorite = UserFavorite.objects.get(
project=project_id,
+ entity_type="cycle",
user=request.user,
workspace__slug=slug,
- cycle_id=cycle_id,
+ entity_identifier=cycle_id,
)
cycle_favorite.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
diff --git a/apiserver/plane/app/views/cycle/issue.py b/apiserver/plane/app/views/cycle/issue.py
index 9a029eb25..fdc998f6d 100644
--- a/apiserver/plane/app/views/cycle/issue.py
+++ b/apiserver/plane/app/views/cycle/issue.py
@@ -23,7 +23,7 @@ from rest_framework.response import Response
from rest_framework import status
# Module imports
-from .. import BaseViewSet, WebhookMixin
+from .. import BaseViewSet
from plane.app.serializers import (
IssueSerializer,
CycleIssueSerializer,
@@ -40,7 +40,7 @@ from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter
-class CycleIssueViewSet(WebhookMixin, BaseViewSet):
+class CycleIssueViewSet(BaseViewSet):
serializer_class = CycleIssueSerializer
model = CycleIssue
@@ -254,6 +254,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
update_cycle_issue_activity = []
# Iterate over each cycle_issue in cycle_issues
for cycle_issue in cycle_issues:
+ old_cycle_id = cycle_issue.cycle_id
# Update the cycle_issue's cycle_id
cycle_issue.cycle_id = cycle_id
# Add the modified cycle_issue to the records_to_update list
@@ -261,7 +262,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
# Record the update activity
update_cycle_issue_activity.append(
{
- "old_cycle_id": str(cycle_issue.cycle_id),
+ "old_cycle_id": str(old_cycle_id),
"new_cycle_id": str(cycle_id),
"issue_id": str(cycle_issue.issue_id),
}
diff --git a/apiserver/plane/app/views/inbox/base.py b/apiserver/plane/app/views/inbox/base.py
index 8e433a127..7919899fa 100644
--- a/apiserver/plane/app/views/inbox/base.py
+++ b/apiserver/plane/app/views/inbox/base.py
@@ -251,6 +251,16 @@ class InboxIssueViewSet(BaseViewSet):
)
if serializer.is_valid():
serializer.save()
+ inbox_id = Inbox.objects.filter(
+ workspace__slug=slug, project_id=project_id
+ ).first()
+ # create an inbox issue
+ inbox_issue = InboxIssue.objects.create(
+ inbox_id=inbox_id.id,
+ project_id=project_id,
+ issue_id=serializer.data["id"],
+ source=request.data.get("source", "in-app"),
+ )
# Create an Issue Activity
issue_activity.delay(
type="issue.activity.created",
@@ -262,16 +272,7 @@ class InboxIssueViewSet(BaseViewSet):
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
- )
- inbox_id = Inbox.objects.filter(
- workspace__slug=slug, project_id=project_id
- ).first()
- # create an inbox issue
- inbox_issue = InboxIssue.objects.create(
- inbox_id=inbox_id.id,
- project_id=project_id,
- issue_id=serializer.data["id"],
- source=request.data.get("source", "in-app"),
+ inbox=str(inbox_issue.id),
)
inbox_issue = (
InboxIssue.objects.select_related("issue")
@@ -339,7 +340,24 @@ class InboxIssueViewSet(BaseViewSet):
# Get issue data
issue_data = request.data.pop("issue", False)
if bool(issue_data):
- issue = Issue.objects.get(
+ issue = Issue.objects.annotate(
+ label_ids=Coalesce(
+ ArrayAgg(
+ "labels__id",
+ distinct=True,
+ filter=~Q(labels__id__isnull=True),
+ ),
+ Value([], output_field=ArrayField(UUIDField())),
+ ),
+ assignee_ids=Coalesce(
+ ArrayAgg(
+ "assignees__id",
+ distinct=True,
+ filter=~Q(assignees__id__isnull=True),
+ ),
+ Value([], output_field=ArrayField(UUIDField())),
+ ),
+ ).get(
pk=inbox_issue.issue_id,
workspace__slug=slug,
project_id=project_id,
@@ -379,6 +397,7 @@ class InboxIssueViewSet(BaseViewSet):
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
+ inbox=str(inbox_issue.id),
)
issue_serializer.save()
else:
@@ -444,15 +463,11 @@ class InboxIssueViewSet(BaseViewSet):
epoch=int(timezone.now().timestamp()),
notification=False,
origin=request.META.get("HTTP_ORIGIN"),
+ inbox=(inbox_issue.id),
)
inbox_issue = (
- InboxIssue.objects.filter(
- inbox_id=inbox_id.id,
- issue_id=serializer.data["id"],
- project_id=project_id,
- )
- .select_related("issue")
+ InboxIssue.objects.select_related("issue")
.prefetch_related(
"issue__labels",
"issue__assignees",
@@ -464,10 +479,7 @@ class InboxIssueViewSet(BaseViewSet):
distinct=True,
filter=~Q(issue__labels__id__isnull=True),
),
- Value(
- [],
- output_field=ArrayField(UUIDField()),
- ),
+ Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
@@ -475,12 +487,14 @@ class InboxIssueViewSet(BaseViewSet):
distinct=True,
filter=~Q(issue__assignees__id__isnull=True),
),
- Value(
- [],
- output_field=ArrayField(UUIDField()),
- ),
+ Value([], output_field=ArrayField(UUIDField())),
),
- ).first()
+ )
+ .get(
+ inbox_id=inbox_id.id,
+ issue_id=issue_id,
+ project_id=project_id,
+ )
)
serializer = InboxIssueDetailSerializer(inbox_issue).data
return Response(serializer, status=status.HTTP_200_OK)
diff --git a/apiserver/plane/app/views/issue/archive.py b/apiserver/plane/app/views/issue/archive.py
index af019a7ec..cc3a343d2 100644
--- a/apiserver/plane/app/views/issue/archive.py
+++ b/apiserver/plane/app/views/issue/archive.py
@@ -241,9 +241,9 @@ class IssueArchiveViewSet(BaseViewSet):
)
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
- issue_queryset, datetime_fields, request.user.user_timezone
+ issues, datetime_fields, request.user.user_timezone
)
-
+
return Response(issues, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, pk=None):
diff --git a/apiserver/plane/app/views/issue/base.py b/apiserver/plane/app/views/issue/base.py
index 7a0e5d9b1..fad85b79d 100644
--- a/apiserver/plane/app/views/issue/base.py
+++ b/apiserver/plane/app/views/issue/base.py
@@ -53,7 +53,7 @@ from plane.utils.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter
# Module imports
-from .. import BaseAPIView, BaseViewSet, WebhookMixin
+from .. import BaseAPIView, BaseViewSet
class IssueListEndpoint(BaseAPIView):
@@ -249,7 +249,7 @@ class IssueListEndpoint(BaseAPIView):
return Response(issues, status=status.HTTP_200_OK)
-class IssueViewSet(WebhookMixin, BaseViewSet):
+class IssueViewSet(BaseViewSet):
def get_serializer_class(self):
return (
IssueCreateSerializer
@@ -447,7 +447,7 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
)
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
- issue_queryset, datetime_fields, request.user.user_timezone
+ issues, datetime_fields, request.user.user_timezone
)
return Response(issues, status=status.HTTP_200_OK)
diff --git a/apiserver/plane/app/views/issue/comment.py b/apiserver/plane/app/views/issue/comment.py
index 0d61f1325..1698efef8 100644
--- a/apiserver/plane/app/views/issue/comment.py
+++ b/apiserver/plane/app/views/issue/comment.py
@@ -11,7 +11,7 @@ from rest_framework.response import Response
from rest_framework import status
# Module imports
-from .. import BaseViewSet, WebhookMixin
+from .. import BaseViewSet
from plane.app.serializers import (
IssueCommentSerializer,
CommentReactionSerializer,
@@ -25,7 +25,7 @@ from plane.db.models import (
from plane.bgtasks.issue_activites_task import issue_activity
-class IssueCommentViewSet(WebhookMixin, BaseViewSet):
+class IssueCommentViewSet(BaseViewSet):
serializer_class = IssueCommentSerializer
model = IssueComment
webhook_event = "issue_comment"
diff --git a/apiserver/plane/app/views/issue/draft.py b/apiserver/plane/app/views/issue/draft.py
index fe75c61f1..610c3c468 100644
--- a/apiserver/plane/app/views/issue/draft.py
+++ b/apiserver/plane/app/views/issue/draft.py
@@ -232,7 +232,7 @@ class IssueDraftViewSet(BaseViewSet):
)
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
- issue_queryset, datetime_fields, request.user.user_timezone
+ issues, datetime_fields, request.user.user_timezone
)
return Response(issues, status=status.HTTP_200_OK)
diff --git a/apiserver/plane/app/views/module/archive.py b/apiserver/plane/app/views/module/archive.py
index 8a5345ff4..2cac5f366 100644
--- a/apiserver/plane/app/views/module/archive.py
+++ b/apiserver/plane/app/views/module/archive.py
@@ -25,12 +25,7 @@ from plane.app.permissions import (
from plane.app.serializers import (
ModuleDetailSerializer,
)
-from plane.db.models import (
- Issue,
- Module,
- ModuleFavorite,
- ModuleLink,
-)
+from plane.db.models import Issue, Module, ModuleLink, UserFavorite
from plane.utils.analytics_plot import burndown_plot
from plane.utils.user_timezone_converter import user_timezone_converter
@@ -46,9 +41,10 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
]
def get_queryset(self):
- favorite_subquery = ModuleFavorite.objects.filter(
+ favorite_subquery = UserFavorite.objects.filter(
user=self.request.user,
- module_id=OuterRef("pk"),
+ entity_identifier=OuterRef("pk"),
+ entity_type="module",
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
diff --git a/apiserver/plane/app/views/module/base.py b/apiserver/plane/app/views/module/base.py
index 59f26a036..f98e0fbc2 100644
--- a/apiserver/plane/app/views/module/base.py
+++ b/apiserver/plane/app/views/module/base.py
@@ -1,6 +1,7 @@
# Python imports
import json
+# Django Imports
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import (
@@ -17,21 +18,20 @@ from django.db.models import (
Value,
)
from django.db.models.functions import Coalesce
-
-# Django Imports
+from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone
-from rest_framework import status
# Third party imports
+from rest_framework import status
from rest_framework.response import Response
+# Module imports
from plane.app.permissions import (
ProjectEntityPermission,
ProjectLitePermission,
)
from plane.app.serializers import (
ModuleDetailSerializer,
- ModuleFavoriteSerializer,
ModuleLinkSerializer,
ModuleSerializer,
ModuleUserPropertiesSerializer,
@@ -41,7 +41,7 @@ from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Issue,
Module,
- ModuleFavorite,
+ UserFavorite,
ModuleIssue,
ModuleLink,
ModuleUserProperties,
@@ -49,13 +49,11 @@ from plane.db.models import (
)
from plane.utils.analytics_plot import burndown_plot
from plane.utils.user_timezone_converter import user_timezone_converter
+from plane.bgtasks.webhook_task import model_activity
+from .. import BaseAPIView, BaseViewSet
-# Module imports
-from .. import BaseAPIView, BaseViewSet, WebhookMixin
-
-
-class ModuleViewSet(WebhookMixin, BaseViewSet):
+class ModuleViewSet(BaseViewSet):
model = Module
permission_classes = [
ProjectEntityPermission,
@@ -70,9 +68,10 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
)
def get_queryset(self):
- favorite_subquery = ModuleFavorite.objects.filter(
+ favorite_subquery = UserFavorite.objects.filter(
user=self.request.user,
- module_id=OuterRef("pk"),
+ entity_type="module",
+ entity_identifier=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
@@ -238,6 +237,16 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
"updated_at",
)
).first()
+ # Send the model activity
+ model_activity.delay(
+ model_name="module",
+ model_id=str(module["id"]),
+ requested_data=request.data,
+ current_instance=None,
+ actor_id=request.user.id,
+ slug=slug,
+ origin=request.META.get("HTTP_ORIGIN"),
+ )
datetime_fields = ["created_at", "updated_at"]
module = user_timezone_converter(
module, datetime_fields, request.user.user_timezone
@@ -428,6 +437,9 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
{"error": "Archived module cannot be updated"},
status=status.HTTP_400_BAD_REQUEST,
)
+ current_instance = json.dumps(
+ ModuleSerializer(module.first()).data, cls=DjangoJSONEncoder
+ )
serializer = ModuleWriteSerializer(
module.first(), data=request.data, partial=True
)
@@ -464,6 +476,18 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
"created_at",
"updated_at",
).first()
+
+ # Send the model activity
+ model_activity.delay(
+ model_name="module",
+ model_id=str(module["id"]),
+ requested_data=request.data,
+ current_instance=current_instance,
+ actor_id=request.user.id,
+ slug=slug,
+ origin=request.META.get("HTTP_ORIGIN"),
+ )
+
datetime_fields = ["created_at", "updated_at"]
module = user_timezone_converter(
module, datetime_fields, request.user.user_timezone
@@ -530,8 +554,7 @@ class ModuleLinkViewSet(BaseViewSet):
class ModuleFavoriteViewSet(BaseViewSet):
- serializer_class = ModuleFavoriteSerializer
- model = ModuleFavorite
+ model = UserFavorite
def get_queryset(self):
return self.filter_queryset(
@@ -543,18 +566,21 @@ class ModuleFavoriteViewSet(BaseViewSet):
)
def create(self, request, slug, project_id):
- serializer = ModuleFavoriteSerializer(data=request.data)
- if serializer.is_valid():
- serializer.save(user=request.user, project_id=project_id)
- return Response(serializer.data, status=status.HTTP_201_CREATED)
- return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+ _ = UserFavorite.objects.create(
+ project_id=project_id,
+ user=request.user,
+ entity_type="module",
+ entity_identifier=request.data.get("module"),
+ )
+ return Response(status=status.HTTP_204_NO_CONTENT)
def destroy(self, request, slug, project_id, module_id):
- module_favorite = ModuleFavorite.objects.get(
- project=project_id,
+ module_favorite = UserFavorite.objects.get(
+ project_id=project_id,
user=request.user,
workspace__slug=slug,
- module_id=module_id,
+ entity_type="module",
+ entity_identifier=module_id,
)
module_favorite.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
diff --git a/apiserver/plane/app/views/module/issue.py b/apiserver/plane/app/views/module/issue.py
index e0fcb2d3c..879ab7e47 100644
--- a/apiserver/plane/app/views/module/issue.py
+++ b/apiserver/plane/app/views/module/issue.py
@@ -16,7 +16,7 @@ from rest_framework.response import Response
from rest_framework import status
# Module imports
-from .. import BaseViewSet, WebhookMixin
+from .. import BaseViewSet
from plane.app.serializers import (
ModuleIssueSerializer,
IssueSerializer,
@@ -33,7 +33,7 @@ from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter
-class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
+class ModuleIssueViewSet(BaseViewSet):
serializer_class = ModuleIssueSerializer
model = ModuleIssue
webhook_event = "module_issue"
@@ -198,46 +198,66 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
]
return Response({"message": "success"}, status=status.HTTP_201_CREATED)
- # create multiple module inside an issue
+ # add multiple module inside an issue and remove multiple modules from an issue
def create_issue_modules(self, request, slug, project_id, issue_id):
modules = request.data.get("modules", [])
- if not modules:
- return Response(
- {"error": "Modules are required"},
- status=status.HTTP_400_BAD_REQUEST,
- )
-
+ removed_modules = request.data.get("removed_modules", [])
project = Project.objects.get(pk=project_id)
- _ = ModuleIssue.objects.bulk_create(
- [
- ModuleIssue(
+
+
+ if modules:
+ _ = ModuleIssue.objects.bulk_create(
+ [
+ ModuleIssue(
+ issue_id=issue_id,
+ module_id=module,
+ project_id=project_id,
+ workspace_id=project.workspace_id,
+ created_by=request.user,
+ updated_by=request.user,
+ )
+ for module in modules
+ ],
+ batch_size=10,
+ ignore_conflicts=True,
+ )
+ # Bulk Update the activity
+ _ = [
+ issue_activity.delay(
+ type="module.activity.created",
+ requested_data=json.dumps({"module_id": module}),
+ actor_id=str(request.user.id),
issue_id=issue_id,
- module_id=module,
project_id=project_id,
- workspace_id=project.workspace_id,
- created_by=request.user,
- updated_by=request.user,
+ current_instance=None,
+ epoch=int(timezone.now().timestamp()),
+ notification=True,
+ origin=request.META.get("HTTP_ORIGIN"),
)
for module in modules
- ],
- batch_size=10,
- ignore_conflicts=True,
- )
- # Bulk Update the activity
- _ = [
- issue_activity.delay(
- type="module.activity.created",
- requested_data=json.dumps({"module_id": module}),
- actor_id=str(request.user.id),
- issue_id=issue_id,
+ ]
+
+ for module_id in removed_modules:
+ module_issue = ModuleIssue.objects.get(
+ workspace__slug=slug,
project_id=project_id,
- current_instance=None,
+ module_id=module_id,
+ issue_id=issue_id,
+ )
+ issue_activity.delay(
+ type="module.activity.deleted",
+ requested_data=json.dumps({"module_id": str(module_id)}),
+ actor_id=str(request.user.id),
+ issue_id=str(issue_id),
+ project_id=str(project_id),
+ current_instance=json.dumps(
+ {"module_name": module_issue.module.name}
+ ),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
- for module in modules
- ]
+ module_issue.delete()
return Response({"message": "success"}, status=status.HTTP_201_CREATED)
diff --git a/apiserver/plane/app/views/oauth.py b/apiserver/plane/app/views/oauth.py
deleted file mode 100644
index 48630175a..000000000
--- a/apiserver/plane/app/views/oauth.py
+++ /dev/null
@@ -1,458 +0,0 @@
-# Python imports
-import uuid
-import requests
-import os
-
-# Django imports
-from django.utils import timezone
-
-# Third Party modules
-from rest_framework.response import Response
-from rest_framework import exceptions
-from rest_framework.permissions import AllowAny
-from rest_framework_simplejwt.tokens import RefreshToken
-from rest_framework import status
-from sentry_sdk import capture_exception
-
-# sso authentication
-from google.oauth2 import id_token
-from google.auth.transport import requests as google_auth_request
-
-# Module imports
-from plane.db.models import (
- SocialLoginConnection,
- User,
- WorkspaceMemberInvite,
- WorkspaceMember,
- ProjectMemberInvite,
- ProjectMember,
-)
-from plane.bgtasks.event_tracking_task import auth_events
-from .base import BaseAPIView
-from plane.license.models import Instance
-from plane.license.utils.instance_value import get_configuration_value
-
-
-def get_tokens_for_user(user):
- refresh = RefreshToken.for_user(user)
- return (
- str(refresh.access_token),
- str(refresh),
- )
-
-
-def validate_google_token(token, client_id):
- try:
- id_info = id_token.verify_oauth2_token(
- token, google_auth_request.Request(), client_id
- )
- email = id_info.get("email")
- first_name = id_info.get("given_name")
- last_name = id_info.get("family_name", "")
- data = {
- "email": email,
- "first_name": first_name,
- "last_name": last_name,
- }
- return data
- except Exception as e:
- capture_exception(e)
- raise exceptions.AuthenticationFailed("Error with Google connection.")
-
-
-def get_access_token(request_token: str, client_id: str) -> str:
- """Obtain the request token from github.
- Given the client id, client secret and request issued out by GitHub, this method
- should give back an access token
- Parameters
- ----------
- CLIENT_ID: str
- A string representing the client id issued out by github
- CLIENT_SECRET: str
- A string representing the client secret issued out by github
- request_token: str
- A string representing the request token issued out by github
- Throws
- ------
- ValueError:
- if CLIENT_ID or CLIENT_SECRET or request_token is empty or not a string
- Returns
- -------
- access_token: str
- A string representing the access token issued out by github
- """
-
- if not request_token:
- raise ValueError("The request token has to be supplied!")
-
- (CLIENT_SECRET,) = get_configuration_value(
- [
- {
- "key": "GITHUB_CLIENT_SECRET",
- "default": os.environ.get("GITHUB_CLIENT_SECRET", None),
- },
- ]
- )
-
- url = f"https://github.com/login/oauth/access_token?client_id={client_id}&client_secret={CLIENT_SECRET}&code={request_token}"
- headers = {"accept": "application/json"}
-
- res = requests.post(url, headers=headers)
-
- data = res.json()
- access_token = data["access_token"]
-
- return access_token
-
-
-def get_user_data(access_token: str) -> dict:
- """
- Obtain the user data from github.
- Given the access token, this method should give back the user data
- """
- if not access_token:
- raise ValueError("The request token has to be supplied!")
- if not isinstance(access_token, str):
- raise ValueError("The request token has to be a string!")
-
- access_token = "token " + access_token
- url = "https://api.github.com/user"
- headers = {"Authorization": access_token}
-
- resp = requests.get(url=url, headers=headers)
-
- user_data = resp.json()
-
- response = requests.get(
- url="https://api.github.com/user/emails", headers=headers
- ).json()
-
- _ = [
- user_data.update({"email": item.get("email")})
- for item in response
- if item.get("primary") is True
- ]
-
- return user_data
-
-
-class OauthEndpoint(BaseAPIView):
- permission_classes = [AllowAny]
-
- def post(self, request):
- try:
- # Check if instance is registered or not
- instance = Instance.objects.first()
- if instance is None and not instance.is_setup_done:
- return Response(
- {"error": "Instance is not configured"},
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- medium = request.data.get("medium", False)
- id_token = request.data.get("credential", False)
- client_id = request.data.get("clientId", False)
-
- 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 medium or not id_token:
- return Response(
- {
- "error": "Something went wrong. Please try again later or contact the support team."
- },
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- if medium == "google":
- if not GOOGLE_CLIENT_ID:
- return Response(
- {"error": "Google login is not configured"},
- status=status.HTTP_400_BAD_REQUEST,
- )
- data = validate_google_token(id_token, client_id)
-
- if medium == "github":
- if not GITHUB_CLIENT_ID:
- return Response(
- {"error": "Github login is not configured"},
- status=status.HTTP_400_BAD_REQUEST,
- )
- access_token = get_access_token(id_token, client_id)
- data = get_user_data(access_token)
-
- email = data.get("email", None)
- if email is None:
- return Response(
- {
- "error": "Something went wrong. Please try again later or contact the support team."
- },
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- if "@" in email:
- user = User.objects.get(email=email)
- email = data["email"]
- mobile_number = uuid.uuid4().hex
- email_verified = True
- else:
- return Response(
- {
- "error": "Something went wrong. Please try again later or contact the support team."
- },
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- 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_medium = "oauth"
- user.last_login_uagent = request.META.get("HTTP_USER_AGENT")
- user.is_email_verified = email_verified
- user.save()
-
- # Check if user has any accepted invites for workspace and add them to workspace
- workspace_member_invites = WorkspaceMemberInvite.objects.filter(
- email=user.email, accepted=True
- )
-
- WorkspaceMember.objects.bulk_create(
- [
- WorkspaceMember(
- workspace_id=workspace_member_invite.workspace_id,
- member=user,
- role=workspace_member_invite.role,
- )
- for workspace_member_invite in workspace_member_invites
- ],
- ignore_conflicts=True,
- )
-
- # Check if user has any project invites
- project_member_invites = ProjectMemberInvite.objects.filter(
- email=user.email, accepted=True
- )
-
- # Add user to workspace
- WorkspaceMember.objects.bulk_create(
- [
- WorkspaceMember(
- workspace_id=project_member_invite.workspace_id,
- role=(
- project_member_invite.role
- if project_member_invite.role in [5, 10, 15]
- else 15
- ),
- member=user,
- created_by_id=project_member_invite.created_by_id,
- )
- for project_member_invite in project_member_invites
- ],
- ignore_conflicts=True,
- )
-
- # Now add the users to project
- ProjectMember.objects.bulk_create(
- [
- ProjectMember(
- workspace_id=project_member_invite.workspace_id,
- role=(
- project_member_invite.role
- if project_member_invite.role in [5, 10, 15]
- else 15
- ),
- member=user,
- created_by_id=project_member_invite.created_by_id,
- )
- for project_member_invite in project_member_invites
- ],
- ignore_conflicts=True,
- )
- # Delete all the invites
- workspace_member_invites.delete()
- project_member_invites.delete()
-
- SocialLoginConnection.objects.update_or_create(
- medium=medium,
- extra_data={},
- user=user,
- defaults={
- "token_data": {"id_token": id_token},
- "last_login_at": timezone.now(),
- },
- )
-
- # 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=medium.upper(),
- first_time=False,
- )
-
- 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)
-
- except User.DoesNotExist:
- (ENABLE_SIGNUP,) = get_configuration_value(
- [
- {
- "key": "ENABLE_SIGNUP",
- "default": os.environ.get("ENABLE_SIGNUP", "0"),
- }
- ]
- )
- 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,
- )
-
- username = uuid.uuid4().hex
-
- if "@" in email:
- email = data["email"]
- mobile_number = uuid.uuid4().hex
- email_verified = True
- else:
- return Response(
- {
- "error": "Something went wrong. Please try again later or contact the support team."
- },
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- user = User.objects.create(
- username=username,
- email=email,
- mobile_number=mobile_number,
- first_name=data.get("first_name", ""),
- last_name=data.get("last_name", ""),
- is_email_verified=email_verified,
- is_password_autoset=True,
- )
-
- user.set_password(uuid.uuid4().hex)
- user.last_active = timezone.now()
- user.last_login_time = timezone.now()
- user.last_login_ip = request.META.get("REMOTE_ADDR")
- user.last_login_medium = "oauth"
- user.last_login_uagent = request.META.get("HTTP_USER_AGENT")
- user.token_updated_at = timezone.now()
- user.save()
-
- # Check if user has any accepted invites for workspace and add them to workspace
- workspace_member_invites = WorkspaceMemberInvite.objects.filter(
- email=user.email, accepted=True
- )
-
- WorkspaceMember.objects.bulk_create(
- [
- WorkspaceMember(
- workspace_id=workspace_member_invite.workspace_id,
- member=user,
- role=workspace_member_invite.role,
- )
- for workspace_member_invite in workspace_member_invites
- ],
- ignore_conflicts=True,
- )
-
- # Check if user has any project invites
- project_member_invites = ProjectMemberInvite.objects.filter(
- email=user.email, accepted=True
- )
-
- # Add user to workspace
- WorkspaceMember.objects.bulk_create(
- [
- WorkspaceMember(
- workspace_id=project_member_invite.workspace_id,
- role=(
- project_member_invite.role
- if project_member_invite.role in [5, 10, 15]
- else 15
- ),
- member=user,
- created_by_id=project_member_invite.created_by_id,
- )
- for project_member_invite in project_member_invites
- ],
- ignore_conflicts=True,
- )
-
- # Now add the users to project
- ProjectMember.objects.bulk_create(
- [
- ProjectMember(
- workspace_id=project_member_invite.workspace_id,
- role=(
- project_member_invite.role
- if project_member_invite.role in [5, 10, 15]
- else 15
- ),
- member=user,
- created_by_id=project_member_invite.created_by_id,
- )
- for project_member_invite in project_member_invites
- ],
- ignore_conflicts=True,
- )
- # Delete all the invites
- workspace_member_invites.delete()
- project_member_invites.delete()
-
- # 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 up",
- medium=medium.upper(),
- first_time=True,
- )
-
- SocialLoginConnection.objects.update_or_create(
- medium=medium,
- extra_data={},
- user=user,
- defaults={
- "token_data": {"id_token": id_token},
- "last_login_at": timezone.now(),
- },
- )
-
- access_token, refresh_token = get_tokens_for_user(user)
- data = {
- "access_token": access_token,
- "refresh_token": refresh_token,
- }
-
- return Response(data, status=status.HTTP_201_CREATED)
diff --git a/apiserver/plane/app/views/page/base.py b/apiserver/plane/app/views/page/base.py
index 29dc2dbf5..16ea78033 100644
--- a/apiserver/plane/app/views/page/base.py
+++ b/apiserver/plane/app/views/page/base.py
@@ -15,7 +15,6 @@ from rest_framework.response import Response
from plane.app.permissions import ProjectEntityPermission
from plane.app.serializers import (
- PageFavoriteSerializer,
PageLogSerializer,
PageSerializer,
SubPageSerializer,
@@ -23,8 +22,8 @@ from plane.app.serializers import (
)
from plane.db.models import (
Page,
- PageFavorite,
PageLog,
+ UserFavorite,
ProjectMember,
)
@@ -61,9 +60,10 @@ class PageViewSet(BaseViewSet):
]
def get_queryset(self):
- subquery = PageFavorite.objects.filter(
+ subquery = UserFavorite.objects.filter(
user=self.request.user,
- page_id=OuterRef("pk"),
+ entity_type="page",
+ entity_identifier=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
@@ -303,23 +303,24 @@ class PageFavoriteViewSet(BaseViewSet):
ProjectEntityPermission,
]
- serializer_class = PageFavoriteSerializer
- model = PageFavorite
+ model = UserFavorite
def create(self, request, slug, project_id, pk):
- _ = PageFavorite.objects.create(
+ _ = UserFavorite.objects.create(
project_id=project_id,
- page_id=pk,
+ entity_identifier=pk,
+ entity_type="page",
user=request.user,
)
return Response(status=status.HTTP_204_NO_CONTENT)
def destroy(self, request, slug, project_id, pk):
- page_favorite = PageFavorite.objects.get(
+ page_favorite = UserFavorite.objects.get(
project=project_id,
user=request.user,
workspace__slug=slug,
- page_id=pk,
+ entity_identifier=pk,
+ entity_type="page",
)
page_favorite.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py
index d8791ae9b..39db11871 100644
--- a/apiserver/plane/app/views/project/base.py
+++ b/apiserver/plane/app/views/project/base.py
@@ -1,5 +1,6 @@
# Python imports
import boto3
+import json
# Django imports
from django.db import IntegrityError
@@ -14,6 +15,7 @@ from django.db.models import (
)
from django.conf import settings
from django.utils import timezone
+from django.core.serializers.json import DjangoJSONEncoder
# Third Party imports
from rest_framework.response import Response
@@ -22,11 +24,10 @@ from rest_framework import serializers
from rest_framework.permissions import AllowAny
# Module imports
-from plane.app.views.base import BaseViewSet, BaseAPIView, WebhookMixin
+from plane.app.views.base import BaseViewSet, BaseAPIView
from plane.app.serializers import (
ProjectSerializer,
ProjectListSerializer,
- ProjectFavoriteSerializer,
ProjectDeployBoardSerializer,
)
@@ -40,7 +41,7 @@ from plane.db.models import (
ProjectMember,
Workspace,
State,
- ProjectFavorite,
+ UserFavorite,
ProjectIdentifier,
Module,
Cycle,
@@ -50,9 +51,10 @@ from plane.db.models import (
Issue,
)
from plane.utils.cache import cache_response
+from plane.bgtasks.webhook_task import model_activity
-class ProjectViewSet(WebhookMixin, BaseViewSet):
+class ProjectViewSet(BaseViewSet):
serializer_class = ProjectListSerializer
model = Project
webhook_event = "project"
@@ -87,10 +89,11 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
)
.annotate(
is_favorite=Exists(
- ProjectFavorite.objects.filter(
+ UserFavorite.objects.filter(
user=self.request.user,
+ entity_identifier=OuterRef("pk"),
+ entity_type="project",
project_id=OuterRef("pk"),
- workspace__slug=self.kwargs.get("slug"),
)
)
)
@@ -334,6 +337,17 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
.filter(pk=serializer.data["id"])
.first()
)
+
+ model_activity.delay(
+ model_name="project",
+ model_id=str(project.id),
+ requested_data=request.data,
+ current_instance=None,
+ actor_id=request.user.id,
+ slug=slug,
+ origin=request.META.get("HTTP_ORIGIN"),
+ )
+
serializer = ProjectListSerializer(project)
return Response(
serializer.data, status=status.HTTP_201_CREATED
@@ -364,7 +378,9 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(pk=pk)
-
+ current_instance = json.dumps(
+ ProjectSerializer(project).data, cls=DjangoJSONEncoder
+ )
if project.archived_at:
return Response(
{"error": "Archived projects cannot be updated"},
@@ -402,6 +418,16 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
.filter(pk=serializer.data["id"])
.first()
)
+
+ model_activity.delay(
+ model_name="project",
+ model_id=str(project.id),
+ requested_data=request.data,
+ current_instance=current_instance,
+ actor_id=request.user.id,
+ slug=slug,
+ origin=request.META.get("HTTP_ORIGIN"),
+ )
serializer = ProjectListSerializer(project)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(
@@ -534,8 +560,7 @@ class ProjectUserViewsEndpoint(BaseAPIView):
class ProjectFavoritesViewSet(BaseViewSet):
- serializer_class = ProjectFavoriteSerializer
- model = ProjectFavorite
+ model = UserFavorite
def get_queryset(self):
return self.filter_queryset(
@@ -553,15 +578,21 @@ class ProjectFavoritesViewSet(BaseViewSet):
serializer.save(user=self.request.user)
def create(self, request, slug):
- serializer = ProjectFavoriteSerializer(data=request.data)
- if serializer.is_valid():
- serializer.save(user=request.user)
- return Response(serializer.data, status=status.HTTP_201_CREATED)
- return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+ _ = UserFavorite.objects.create(
+ user=request.user,
+ entity_type="project",
+ entity_identifier=request.data.get("project"),
+ project_id=request.data.get("project"),
+ )
+ return Response(status=status.HTTP_204_NO_CONTENT)
def destroy(self, request, slug, project_id):
- project_favorite = ProjectFavorite.objects.get(
- project=project_id, user=request.user, workspace__slug=slug
+ project_favorite = UserFavorite.objects.get(
+ entity_identifier=project_id,
+ entity_type="project",
+ project=project_id,
+ user=request.user,
+ workspace__slug=slug,
)
project_favorite.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -576,11 +607,19 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView):
@cache_response(60 * 60 * 24, user=False)
def get(self, request):
files = []
- s3 = boto3.client(
- "s3",
- aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
- aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
- )
+ if settings.USE_MINIO:
+ s3 = boto3.client(
+ "s3",
+ endpoint_url=settings.AWS_S3_ENDPOINT_URL,
+ aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
+ aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
+ )
+ else:
+ s3 = boto3.client(
+ "s3",
+ aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
+ aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
+ )
params = {
"Bucket": settings.AWS_STORAGE_BUCKET_NAME,
"Prefix": "static/project-cover/",
diff --git a/apiserver/plane/app/views/search.py b/apiserver/plane/app/views/search.py
index 4a4ffd826..93bab2de3 100644
--- a/apiserver/plane/app/views/search.py
+++ b/apiserver/plane/app/views/search.py
@@ -289,6 +289,7 @@ class IssueSearchEndpoint(BaseAPIView):
issues.values(
"name",
"id",
+ "start_date",
"sequence_id",
"project__name",
"project__identifier",
diff --git a/apiserver/plane/app/views/user/base.py b/apiserver/plane/app/views/user/base.py
index 487e365cd..9a9cdde43 100644
--- a/apiserver/plane/app/views/user/base.py
+++ b/apiserver/plane/app/views/user/base.py
@@ -1,22 +1,40 @@
+# Python imports
+# import uuid
+
# Django imports
from django.db.models import Case, Count, IntegerField, Q, When
+from django.contrib.auth import logout
+from django.utils import timezone
# Third party imports
from rest_framework import status
from rest_framework.response import Response
+from rest_framework.permissions import AllowAny
# Module imports
from plane.app.serializers import (
+ AccountSerializer,
IssueActivitySerializer,
+ ProfileSerializer,
UserMeSerializer,
UserMeSettingsSerializer,
UserSerializer,
)
from plane.app.views.base import BaseAPIView, BaseViewSet
-from plane.db.models import IssueActivity, ProjectMember, User, WorkspaceMember
+from plane.db.models import (
+ Account,
+ IssueActivity,
+ Profile,
+ ProjectMember,
+ User,
+ WorkspaceMember,
+ WorkspaceMemberInvite,
+ Session,
+)
from plane.license.models import Instance, InstanceAdmin
from plane.utils.cache import cache_response, invalidate_cache
from plane.utils.paginator import BasePaginator
+from plane.authentication.utils.host import user_ip
class UserEndpoint(BaseViewSet):
@@ -141,28 +159,70 @@ class UserEndpoint(BaseViewSet):
workspaces_to_deactivate, ["is_active"], batch_size=100
)
- # Deactivate the user
- user.is_active = False
- user.last_workspace_id = None
- user.is_tour_completed = False
- user.is_onboarded = False
- user.onboarding_step = {
+ # Delete all workspace invites
+ WorkspaceMemberInvite.objects.filter(
+ email=user.email,
+ ).delete()
+
+ # Delete all sessions
+ Session.objects.filter(user_id=request.user.id).delete()
+
+ # Profile updates
+ profile = Profile.objects.get(user=user)
+
+ # Reset onboarding
+ profile.last_workspace_id = None
+ profile.is_tour_completed = False
+ profile.is_onboarded = False
+ profile.onboarding_step = {
"workspace_join": False,
"profile_complete": False,
"workspace_create": False,
"workspace_invite": False,
}
+ profile.save()
+
+ # Reset password
+ # user.is_password_autoset = True
+ # user.set_password(uuid.uuid4().hex)
+
+ # Deactivate the user
+ user.is_active = False
+ user.last_logout_ip = user_ip(request=request)
+ user.last_logout_time = timezone.now()
user.save()
+
+ # Logout the user
+ logout(request)
return Response(status=status.HTTP_204_NO_CONTENT)
+class UserSessionEndpoint(BaseAPIView):
+
+ permission_classes = [
+ AllowAny,
+ ]
+
+ def get(self, request):
+ if request.user.is_authenticated:
+ user = User.objects.get(pk=request.user.id)
+ serializer = UserMeSerializer(user)
+ data = {"is_authenticated": True}
+ data["user"] = serializer.data
+ return Response(data, status=status.HTTP_200_OK)
+ else:
+ return Response(
+ {"is_authenticated": False}, status=status.HTTP_200_OK
+ )
+
+
class UpdateUserOnBoardedEndpoint(BaseAPIView):
@invalidate_cache(path="/api/users/me/")
def patch(self, request):
- user = User.objects.get(pk=request.user.id, is_active=True)
- user.is_onboarded = request.data.get("is_onboarded", False)
- user.save()
+ profile = Profile.objects.get(user_id=request.user.id)
+ profile.is_onboarded = request.data.get("is_onboarded", False)
+ profile.save()
return Response(
{"message": "Updated successfully"}, status=status.HTTP_200_OK
)
@@ -172,9 +232,11 @@ class UpdateUserTourCompletedEndpoint(BaseAPIView):
@invalidate_cache(path="/api/users/me/")
def patch(self, request):
- user = User.objects.get(pk=request.user.id, is_active=True)
- user.is_tour_completed = request.data.get("is_tour_completed", False)
- user.save()
+ profile = Profile.objects.get(user_id=request.user.id)
+ profile.is_tour_completed = request.data.get(
+ "is_tour_completed", False
+ )
+ profile.save()
return Response(
{"message": "Updated successfully"}, status=status.HTTP_200_OK
)
@@ -194,3 +256,42 @@ class UserActivityEndpoint(BaseAPIView, BasePaginator):
issue_activities, many=True
).data,
)
+
+
+class AccountEndpoint(BaseAPIView):
+
+ def get(self, request, pk=None):
+ if pk:
+ account = Account.objects.get(pk=pk, user=request.user)
+ serializer = AccountSerializer(account)
+ return Response(serializer.data, status=status.HTTP_200_OK)
+
+ account = Account.objects.filter(user=request.user)
+ serializer = AccountSerializer(account, many=True)
+ return Response(
+ serializer.data,
+ status=status.HTTP_200_OK,
+ )
+
+ def delete(self, request, pk):
+ account = Account.objects.get(pk=pk, user=request.user)
+ account.delete()
+ return Response(status=status.HTTP_204_NO_CONTENT)
+
+
+class ProfileEndpoint(BaseAPIView):
+ def get(self, request):
+ profile = Profile.objects.get(user=request.user)
+ serializer = ProfileSerializer(profile)
+ return Response(serializer.data, status=status.HTTP_200_OK)
+
+ @invalidate_cache("/api/users/me/settings/")
+ def patch(self, request):
+ profile = Profile.objects.get(user=request.user)
+ serializer = ProfileSerializer(
+ profile, data=request.data, partial=True
+ )
+ if serializer.is_valid():
+ serializer.save()
+ return Response(serializer.data, status=status.HTTP_200_OK)
+ return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
diff --git a/apiserver/plane/app/views/view/base.py b/apiserver/plane/app/views/view/base.py
index 7736e465c..72c27d20a 100644
--- a/apiserver/plane/app/views/view/base.py
+++ b/apiserver/plane/app/views/view/base.py
@@ -27,7 +27,6 @@ from .. import BaseViewSet
from plane.app.serializers import (
IssueViewSerializer,
IssueSerializer,
- IssueViewFavoriteSerializer,
)
from plane.app.permissions import (
WorkspaceEntityPermission,
@@ -37,7 +36,7 @@ from plane.db.models import (
Workspace,
IssueView,
Issue,
- IssueViewFavorite,
+ UserFavorite,
IssueLink,
IssueAttachment,
)
@@ -273,9 +272,10 @@ class IssueViewViewSet(BaseViewSet):
serializer.save(project_id=self.kwargs.get("project_id"))
def get_queryset(self):
- subquery = IssueViewFavorite.objects.filter(
+ subquery = UserFavorite.objects.filter(
user=self.request.user,
- view_id=OuterRef("pk"),
+ entity_identifier=OuterRef("pk"),
+ entity_type="view",
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
@@ -310,8 +310,7 @@ class IssueViewViewSet(BaseViewSet):
class IssueViewFavoriteViewSet(BaseViewSet):
- serializer_class = IssueViewFavoriteSerializer
- model = IssueViewFavorite
+ model = UserFavorite
def get_queryset(self):
return self.filter_queryset(
@@ -323,18 +322,21 @@ class IssueViewFavoriteViewSet(BaseViewSet):
)
def create(self, request, slug, project_id):
- serializer = IssueViewFavoriteSerializer(data=request.data)
- if serializer.is_valid():
- serializer.save(user=request.user, project_id=project_id)
- return Response(serializer.data, status=status.HTTP_201_CREATED)
- return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+ _ = UserFavorite.objects.create(
+ user=request.user,
+ entity_identifier=request.data.get("view"),
+ entity_type="view",
+ project_id=project_id,
+ )
+ return Response(status=status.HTTP_204_NO_CONTENT)
def destroy(self, request, slug, project_id, view_id):
- view_favorite = IssueViewFavorite.objects.get(
+ view_favorite = UserFavorite.objects.get(
project=project_id,
user=request.user,
workspace__slug=slug,
- view_id=view_id,
+ entity_type="view",
+ entity_identifier=view_id,
)
view_favorite.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
diff --git a/apiserver/plane/app/views/workspace/base.py b/apiserver/plane/app/views/workspace/base.py
index 24a3d7302..830ae1dc2 100644
--- a/apiserver/plane/app/views/workspace/base.py
+++ b/apiserver/plane/app/views/workspace/base.py
@@ -96,6 +96,7 @@ class WorkSpaceViewSet(BaseViewSet):
@invalidate_cache(path="/api/workspaces/", user=False)
@invalidate_cache(path="/api/users/me/workspaces/")
+ @invalidate_cache(path="/api/instances/", user=False)
def create(self, request):
try:
serializer = WorkSpaceSerializer(data=request.data)
@@ -151,8 +152,12 @@ class WorkSpaceViewSet(BaseViewSet):
return super().partial_update(request, *args, **kwargs)
@invalidate_cache(path="/api/workspaces/", user=False)
- @invalidate_cache(path="/api/users/me/workspaces/", multiple=True, user=False)
- @invalidate_cache(path="/api/users/me/settings/", multiple=True, user=False)
+ @invalidate_cache(
+ path="/api/users/me/workspaces/", multiple=True, user=False
+ )
+ @invalidate_cache(
+ path="/api/users/me/settings/", multiple=True, user=False
+ )
def destroy(self, request, *args, **kwargs):
return super().destroy(request, *args, **kwargs)
diff --git a/apiserver/plane/authentication/__init__.py b/apiserver/plane/authentication/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/apiserver/plane/authentication/adapter/__init__.py b/apiserver/plane/authentication/adapter/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/apiserver/plane/authentication/adapter/base.py b/apiserver/plane/authentication/adapter/base.py
new file mode 100644
index 000000000..7b899e63c
--- /dev/null
+++ b/apiserver/plane/authentication/adapter/base.py
@@ -0,0 +1,130 @@
+# Python imports
+import os
+import uuid
+
+# Django imports
+from django.utils import timezone
+
+# Third party imports
+from zxcvbn import zxcvbn
+
+# Module imports
+from plane.db.models import (
+ Profile,
+ User,
+ WorkspaceMemberInvite,
+)
+from plane.license.utils.instance_value import get_configuration_value
+from .error import AuthenticationException, AUTHENTICATION_ERROR_CODES
+
+
+class Adapter:
+ """Common interface for all auth providers"""
+
+ def __init__(self, request, provider, callback=None):
+ self.request = request
+ self.provider = provider
+ self.callback = callback
+ self.token_data = None
+ self.user_data = None
+
+ def get_user_token(self, data, headers=None):
+ raise NotImplementedError
+
+ def get_user_response(self):
+ raise NotImplementedError
+
+ def set_token_data(self, data):
+ self.token_data = data
+
+ def set_user_data(self, data):
+ self.user_data = data
+
+ def create_update_account(self, user):
+ raise NotImplementedError
+
+ def authenticate(self):
+ raise NotImplementedError
+
+ def complete_login_or_signup(self):
+ email = self.user_data.get("email")
+ user = User.objects.filter(email=email).first()
+ # Check if sign up case or login
+ is_signup = bool(user)
+ if not user:
+ # New user
+ (ENABLE_SIGNUP,) = get_configuration_value(
+ [
+ {
+ "key": "ENABLE_SIGNUP",
+ "default": os.environ.get("ENABLE_SIGNUP", "1"),
+ },
+ ]
+ )
+ if (
+ ENABLE_SIGNUP == "0"
+ and not WorkspaceMemberInvite.objects.filter(
+ email=email,
+ ).exists()
+ ):
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["SIGNUP_DISABLED"],
+ error_message="SIGNUP_DISABLED",
+ payload={"email": email},
+ )
+ user = User(email=email, username=uuid.uuid4().hex)
+
+ if self.user_data.get("user").get("is_password_autoset"):
+ user.set_password(uuid.uuid4().hex)
+ user.is_password_autoset = True
+ user.is_email_verified = True
+ else:
+ # Validate password
+ results = zxcvbn(self.code)
+ if results["score"] < 3:
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INVALID_PASSWORD"
+ ],
+ error_message="INVALID_PASSWORD",
+ payload={"email": email},
+ )
+
+ user.set_password(self.code)
+ user.is_password_autoset = False
+
+ avatar = self.user_data.get("user", {}).get("avatar", "")
+ first_name = self.user_data.get("user", {}).get("first_name", "")
+ last_name = self.user_data.get("user", {}).get("last_name", "")
+ user.avatar = avatar if avatar else ""
+ user.first_name = first_name if first_name else ""
+ user.last_name = last_name if last_name else ""
+ user.save()
+ Profile.objects.create(user=user)
+
+ if not user.is_active:
+ raise AuthenticationException(
+ AUTHENTICATION_ERROR_CODES["USER_ACCOUNT_DEACTIVATED"],
+ error_message="USER_ACCOUNT_DEACTIVATED",
+ )
+
+ # Update user details
+ user.last_login_medium = self.provider
+ user.last_active = timezone.now()
+ user.last_login_time = timezone.now()
+ user.last_login_ip = self.request.META.get("REMOTE_ADDR")
+ user.last_login_uagent = self.request.META.get("HTTP_USER_AGENT")
+ user.token_updated_at = timezone.now()
+ user.save()
+
+ if self.callback:
+ self.callback(
+ user,
+ is_signup,
+ self.request,
+ )
+
+ if self.token_data:
+ self.create_update_account(user=user)
+
+ return user
diff --git a/apiserver/plane/authentication/adapter/credential.py b/apiserver/plane/authentication/adapter/credential.py
new file mode 100644
index 000000000..0327289ca
--- /dev/null
+++ b/apiserver/plane/authentication/adapter/credential.py
@@ -0,0 +1,14 @@
+from plane.authentication.adapter.base import Adapter
+
+
+class CredentialAdapter(Adapter):
+ """Common interface for all credential providers"""
+
+ def __init__(self, request, provider, callback=None):
+ super().__init__(request=request, provider=provider, callback=callback)
+ self.request = request
+ self.provider = provider
+
+ def authenticate(self):
+ self.set_user_data()
+ return self.complete_login_or_signup()
diff --git a/apiserver/plane/authentication/adapter/error.py b/apiserver/plane/authentication/adapter/error.py
new file mode 100644
index 000000000..457a67f4f
--- /dev/null
+++ b/apiserver/plane/authentication/adapter/error.py
@@ -0,0 +1,82 @@
+AUTHENTICATION_ERROR_CODES = {
+ # Global
+ "INSTANCE_NOT_CONFIGURED": 5000,
+ "INVALID_EMAIL": 5005,
+ "EMAIL_REQUIRED": 5010,
+ "SIGNUP_DISABLED": 5015,
+ "MAGIC_LINK_LOGIN_DISABLED": 5016,
+ "PASSWORD_LOGIN_DISABLED": 5018,
+ "USER_ACCOUNT_DEACTIVATED": 5019,
+ # Password strength
+ "INVALID_PASSWORD": 5020,
+ "SMTP_NOT_CONFIGURED": 5025,
+ # Sign Up
+ "USER_ALREADY_EXIST": 5030,
+ "AUTHENTICATION_FAILED_SIGN_UP": 5035,
+ "REQUIRED_EMAIL_PASSWORD_SIGN_UP": 5040,
+ "INVALID_EMAIL_SIGN_UP": 5045,
+ "INVALID_EMAIL_MAGIC_SIGN_UP": 5050,
+ "MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED": 5055,
+ # Sign In
+ "USER_DOES_NOT_EXIST": 5060,
+ "AUTHENTICATION_FAILED_SIGN_IN": 5065,
+ "REQUIRED_EMAIL_PASSWORD_SIGN_IN": 5070,
+ "INVALID_EMAIL_SIGN_IN": 5075,
+ "INVALID_EMAIL_MAGIC_SIGN_IN": 5080,
+ "MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED": 5085,
+ # Both Sign in and Sign up for magic
+ "INVALID_MAGIC_CODE_SIGN_IN": 5090,
+ "INVALID_MAGIC_CODE_SIGN_UP": 5092,
+ "EXPIRED_MAGIC_CODE_SIGN_IN": 5095,
+ "EXPIRED_MAGIC_CODE_SIGN_UP": 5097,
+ "EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN": 5100,
+ "EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP": 5102,
+ # Oauth
+ "GOOGLE_NOT_CONFIGURED": 5105,
+ "GITHUB_NOT_CONFIGURED": 5110,
+ "GOOGLE_OAUTH_PROVIDER_ERROR": 5115,
+ "GITHUB_OAUTH_PROVIDER_ERROR": 5120,
+ # Reset Password
+ "INVALID_PASSWORD_TOKEN": 5125,
+ "EXPIRED_PASSWORD_TOKEN": 5130,
+ # Change password
+ "INCORRECT_OLD_PASSWORD": 5135,
+ "MISSING_PASSWORD": 5138,
+ "INVALID_NEW_PASSWORD": 5140,
+ # set passowrd
+ "PASSWORD_ALREADY_SET": 5145,
+ # Admin
+ "ADMIN_ALREADY_EXIST": 5150,
+ "REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME": 5155,
+ "INVALID_ADMIN_EMAIL": 5160,
+ "INVALID_ADMIN_PASSWORD": 5165,
+ "REQUIRED_ADMIN_EMAIL_PASSWORD": 5170,
+ "ADMIN_AUTHENTICATION_FAILED": 5175,
+ "ADMIN_USER_ALREADY_EXIST": 5180,
+ "ADMIN_USER_DOES_NOT_EXIST": 5185,
+ "ADMIN_USER_DEACTIVATED": 5190,
+ # Rate limit
+ "RATE_LIMIT_EXCEEDED": 5900,
+}
+
+
+class AuthenticationException(Exception):
+
+ error_code = None
+ error_message = None
+ payload = {}
+
+ def __init__(self, error_code, error_message, payload={}):
+ self.error_code = error_code
+ self.error_message = error_message
+ self.payload = payload
+
+ def get_error_dict(self):
+ error = {
+ "error_code": self.error_code,
+ "error_message": self.error_message,
+ }
+ for key in self.payload:
+ error[key] = self.payload[key]
+
+ return error
diff --git a/apiserver/plane/authentication/adapter/exception.py b/apiserver/plane/authentication/adapter/exception.py
new file mode 100644
index 000000000..a6f7637a9
--- /dev/null
+++ b/apiserver/plane/authentication/adapter/exception.py
@@ -0,0 +1,27 @@
+# Third party imports
+from rest_framework.views import exception_handler
+from rest_framework.exceptions import NotAuthenticated
+from rest_framework.exceptions import Throttled
+
+# Module imports
+from plane.authentication.adapter.error import AuthenticationException, AUTHENTICATION_ERROR_CODES
+
+
+def auth_exception_handler(exc, context):
+ # Call the default exception handler first, to get the standard error response.
+ response = exception_handler(exc, context)
+ # Check if an AuthenticationFailed exception is raised.
+ if isinstance(exc, NotAuthenticated):
+ response.status_code = 401
+
+ # Check if an Throttled exception is raised.
+ if isinstance(exc, Throttled):
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["RATE_LIMIT_EXCEEDED"],
+ error_message="RATE_LIMIT_EXCEEDED",
+ )
+ response.data = exc.get_error_dict()
+ response.status_code = 429
+
+ # Return the response that is generated by the default exception handler.
+ return response
diff --git a/apiserver/plane/authentication/adapter/oauth.py b/apiserver/plane/authentication/adapter/oauth.py
new file mode 100644
index 000000000..60c2ea0c6
--- /dev/null
+++ b/apiserver/plane/authentication/adapter/oauth.py
@@ -0,0 +1,90 @@
+# Python imports
+import requests
+
+# Django imports
+from django.utils import timezone
+
+# Module imports
+from plane.db.models import Account
+
+from .base import Adapter
+
+
+class OauthAdapter(Adapter):
+ def __init__(
+ self,
+ request,
+ provider,
+ client_id,
+ scope,
+ redirect_uri,
+ auth_url,
+ token_url,
+ userinfo_url,
+ client_secret=None,
+ code=None,
+ callback=None,
+ ):
+ super().__init__(request=request, provider=provider, callback=callback)
+ self.client_id = client_id
+ self.scope = scope
+ self.redirect_uri = redirect_uri
+ self.auth_url = auth_url
+ self.token_url = token_url
+ self.userinfo_url = userinfo_url
+ self.client_secret = client_secret
+ self.code = code
+
+ def get_auth_url(self):
+ return self.auth_url
+
+ def get_token_url(self):
+ return self.token_url
+
+ def get_user_info_url(self):
+ return self.userinfo_url
+
+ def authenticate(self):
+ self.set_token_data()
+ self.set_user_data()
+ return self.complete_login_or_signup()
+
+ def get_user_token(self, data, headers=None):
+ headers = headers or {}
+ response = requests.post(
+ self.get_token_url(), data=data, headers=headers
+ )
+ response.raise_for_status()
+ return response.json()
+
+ def get_user_response(self):
+ headers = {
+ "Authorization": f"Bearer {self.token_data.get('access_token')}"
+ }
+ response = requests.get(self.get_user_info_url(), headers=headers)
+ response.raise_for_status()
+ return response.json()
+
+ def set_user_data(self, data):
+ self.user_data = data
+
+ def create_update_account(self, user):
+ account, created = Account.objects.update_or_create(
+ user=user,
+ provider=self.provider,
+ defaults={
+ "provider_account_id": self.user_data.get("user").get(
+ "provider_id"
+ ),
+ "access_token": self.token_data.get("access_token"),
+ "refresh_token": self.token_data.get("refresh_token", None),
+ "access_token_expired_at": self.token_data.get(
+ "access_token_expired_at"
+ ),
+ "refresh_token_expired_at": self.token_data.get(
+ "refresh_token_expired_at"
+ ),
+ "last_connected_at": timezone.now(),
+ "id_token": self.token_data.get("id_token", ""),
+ },
+ )
diff --git a/apiserver/plane/authentication/apps.py b/apiserver/plane/authentication/apps.py
new file mode 100644
index 000000000..cf5cdca1c
--- /dev/null
+++ b/apiserver/plane/authentication/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class AuthConfig(AppConfig):
+ name = "plane.authentication"
diff --git a/apiserver/plane/authentication/middleware/__init__.py b/apiserver/plane/authentication/middleware/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/apiserver/plane/authentication/middleware/session.py b/apiserver/plane/authentication/middleware/session.py
new file mode 100644
index 000000000..2bb62b881
--- /dev/null
+++ b/apiserver/plane/authentication/middleware/session.py
@@ -0,0 +1,94 @@
+import time
+from importlib import import_module
+
+from django.conf import settings
+from django.contrib.sessions.backends.base import UpdateError
+from django.contrib.sessions.exceptions import SessionInterrupted
+from django.utils.cache import patch_vary_headers
+from django.utils.deprecation import MiddlewareMixin
+from django.utils.http import http_date
+
+
+class SessionMiddleware(MiddlewareMixin):
+ def __init__(self, get_response):
+ super().__init__(get_response)
+ engine = import_module(settings.SESSION_ENGINE)
+ self.SessionStore = engine.SessionStore
+
+ def process_request(self, request):
+ if "instances" in request.path:
+ session_key = request.COOKIES.get(
+ settings.ADMIN_SESSION_COOKIE_NAME
+ )
+ else:
+ session_key = request.COOKIES.get(settings.SESSION_COOKIE_NAME)
+ request.session = self.SessionStore(session_key)
+
+ def process_response(self, request, response):
+ """
+ If request.session was modified, or if the configuration is to save the
+ session every time, save the changes and set a session cookie or delete
+ the session cookie if the session has been emptied.
+ """
+ try:
+ accessed = request.session.accessed
+ modified = request.session.modified
+ empty = request.session.is_empty()
+ except AttributeError:
+ return response
+ # First check if we need to delete this cookie.
+ # The session should be deleted only if the session is entirely empty.
+ is_admin_path = "instances" in request.path
+ cookie_name = (
+ settings.ADMIN_SESSION_COOKIE_NAME
+ if is_admin_path
+ else settings.SESSION_COOKIE_NAME
+ )
+
+ if cookie_name in request.COOKIES and empty:
+ response.delete_cookie(
+ cookie_name,
+ path=settings.SESSION_COOKIE_PATH,
+ domain=settings.SESSION_COOKIE_DOMAIN,
+ samesite=settings.SESSION_COOKIE_SAMESITE,
+ )
+ patch_vary_headers(response, ("Cookie",))
+ else:
+ if accessed:
+ patch_vary_headers(response, ("Cookie",))
+ if (modified or settings.SESSION_SAVE_EVERY_REQUEST) and not empty:
+ if request.session.get_expire_at_browser_close():
+ max_age = None
+ expires = None
+ else:
+ # Use different max_age based on whether it's an admin cookie
+ if is_admin_path:
+ max_age = settings.ADMIN_SESSION_COOKIE_AGE
+ else:
+ max_age = request.session.get_expiry_age()
+
+ expires_time = time.time() + max_age
+ expires = http_date(expires_time)
+
+ # Save the session data and refresh the client cookie.
+ if response.status_code < 500:
+ try:
+ request.session.save()
+ except UpdateError:
+ raise SessionInterrupted(
+ "The request's session was deleted before the "
+ "request completed. The user may have logged "
+ "out in a concurrent request, for example."
+ )
+ response.set_cookie(
+ cookie_name,
+ request.session.session_key,
+ max_age=max_age,
+ expires=expires,
+ domain=settings.SESSION_COOKIE_DOMAIN,
+ path=settings.SESSION_COOKIE_PATH,
+ secure=settings.SESSION_COOKIE_SECURE or None,
+ httponly=settings.SESSION_COOKIE_HTTPONLY or None,
+ samesite=settings.SESSION_COOKIE_SAMESITE,
+ )
+ return response
diff --git a/apiserver/plane/authentication/provider/__init__.py b/apiserver/plane/authentication/provider/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/apiserver/plane/authentication/provider/credentials/__init__.py b/apiserver/plane/authentication/provider/credentials/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/apiserver/plane/authentication/provider/credentials/email.py b/apiserver/plane/authentication/provider/credentials/email.py
new file mode 100644
index 000000000..7e4e619d8
--- /dev/null
+++ b/apiserver/plane/authentication/provider/credentials/email.py
@@ -0,0 +1,119 @@
+# Python imports
+import os
+
+# Module imports
+from plane.authentication.adapter.credential import CredentialAdapter
+from plane.db.models import User
+from plane.authentication.adapter.error import (
+ AUTHENTICATION_ERROR_CODES,
+ AuthenticationException,
+)
+from plane.license.utils.instance_value import get_configuration_value
+
+
+class EmailProvider(CredentialAdapter):
+
+ provider = "email"
+
+ def __init__(
+ self,
+ request,
+ key=None,
+ code=None,
+ is_signup=False,
+ callback=None,
+ ):
+ super().__init__(
+ request=request, provider=self.provider, callback=callback
+ )
+ self.key = key
+ self.code = code
+ self.is_signup = is_signup
+
+ (ENABLE_EMAIL_PASSWORD,) = get_configuration_value(
+ [
+ {
+ "key": "ENABLE_EMAIL_PASSWORD",
+ "default": os.environ.get("ENABLE_EMAIL_PASSWORD"),
+ },
+ ]
+ )
+
+ if ENABLE_EMAIL_PASSWORD == "0":
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["ENABLE_EMAIL_PASSWORD"],
+ error_message="ENABLE_EMAIL_PASSWORD",
+ )
+
+ def set_user_data(self):
+ if self.is_signup:
+ # Check if the user already exists
+ if User.objects.filter(email=self.key).exists():
+ raise AuthenticationException(
+ error_message="USER_ALREADY_EXIST",
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "USER_ALREADY_EXIST"
+ ],
+ )
+
+ super().set_user_data(
+ {
+ "email": self.key,
+ "user": {
+ "avatar": "",
+ "first_name": "",
+ "last_name": "",
+ "provider_id": "",
+ "is_password_autoset": False,
+ },
+ }
+ )
+ return
+ else:
+ user = User.objects.filter(
+ email=self.key,
+ ).first()
+
+ # User does not exists
+ if not user:
+ raise AuthenticationException(
+ error_message="USER_DOES_NOT_EXIST",
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "USER_DOES_NOT_EXIST"
+ ],
+ payload={
+ "email": self.key,
+ },
+ )
+
+ # Check user password
+ if not user.check_password(self.code):
+ raise AuthenticationException(
+ error_message=(
+ "AUTHENTICATION_FAILED_SIGN_UP"
+ if self.is_signup
+ else "AUTHENTICATION_FAILED_SIGN_IN"
+ ),
+ error_code=AUTHENTICATION_ERROR_CODES[
+ (
+ "AUTHENTICATION_FAILED_SIGN_UP"
+ if self.is_signup
+ else "AUTHENTICATION_FAILED_SIGN_IN"
+ )
+ ],
+ payload={"email": self.key},
+ )
+
+ super().set_user_data(
+ {
+ "email": self.key,
+ "user": {
+ "avatar": "",
+ "first_name": "",
+ "last_name": "",
+ "provider_id": "",
+ "is_password_autoset": False,
+ },
+ }
+ )
+ return
diff --git a/apiserver/plane/authentication/provider/credentials/magic_code.py b/apiserver/plane/authentication/provider/credentials/magic_code.py
new file mode 100644
index 000000000..21309ea9c
--- /dev/null
+++ b/apiserver/plane/authentication/provider/credentials/magic_code.py
@@ -0,0 +1,180 @@
+# Python imports
+import json
+import os
+import random
+import string
+
+
+# Module imports
+from plane.authentication.adapter.credential import CredentialAdapter
+from plane.license.utils.instance_value import get_configuration_value
+from plane.settings.redis import redis_instance
+from plane.authentication.adapter.error import (
+ AUTHENTICATION_ERROR_CODES,
+ AuthenticationException,
+)
+from plane.db.models import User
+
+
+class MagicCodeProvider(CredentialAdapter):
+
+ provider = "magic-code"
+
+ def __init__(
+ self,
+ request,
+ key,
+ code=None,
+ callback=None,
+ ):
+
+ (
+ EMAIL_HOST,
+ ENABLE_MAGIC_LINK_LOGIN,
+ ) = get_configuration_value(
+ [
+ {
+ "key": "EMAIL_HOST",
+ "default": os.environ.get("EMAIL_HOST"),
+ },
+ {
+ "key": "ENABLE_MAGIC_LINK_LOGIN",
+ "default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "1"),
+ },
+ ]
+ )
+
+ if not (EMAIL_HOST):
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["SMTP_NOT_CONFIGURED"],
+ error_message="SMTP_NOT_CONFIGURED",
+ payload={"email": str(self.key)},
+ )
+
+ if ENABLE_MAGIC_LINK_LOGIN == "0":
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "MAGIC_LINK_LOGIN_DISABLED"
+ ],
+ error_message="MAGIC_LINK_LOGIN_DISABLED",
+ payload={"email": str(self.key)},
+ )
+
+ super().__init__(
+ request=request, provider=self.provider, callback=callback
+ )
+ self.key = key
+ self.code = code
+
+ def initiate(self):
+ ## 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(self.key)
+
+ # 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:
+ email = str(self.key).replace("magic_", "", 1)
+ if User.objects.filter(email=email).exists():
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN"
+ ],
+ error_message="EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN",
+ payload={"email": str(email)},
+ )
+ else:
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP"
+ ],
+ error_message="EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP",
+ payload={"email": self.key},
+ )
+
+ value = {
+ "current_attempt": current_attempt,
+ "email": str(self.key),
+ "token": token,
+ }
+ expiry = 600
+ ri.set(key, json.dumps(value), ex=expiry)
+ else:
+ value = {"current_attempt": 0, "email": self.key, "token": token}
+ expiry = 600
+
+ ri.set(key, json.dumps(value), ex=expiry)
+ return key, token
+
+ def set_user_data(self):
+ ri = redis_instance()
+ if ri.exists(self.key):
+ data = json.loads(ri.get(self.key))
+ token = data["token"]
+ email = data["email"]
+
+ if str(token) == str(self.code):
+ super().set_user_data(
+ {
+ "email": email,
+ "user": {
+ "avatar": "",
+ "first_name": "",
+ "last_name": "",
+ "provider_id": "",
+ "is_password_autoset": True,
+ },
+ }
+ )
+ # Delete the token from redis if the code match is successful
+ ri.delete(self.key)
+ return
+ else:
+ email = str(self.key).replace("magic_", "", 1)
+ if User.objects.filter(email=email).exists():
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INVALID_MAGIC_CODE_SIGN_IN"
+ ],
+ error_message="INVALID_MAGIC_CODE_SIGN_IN",
+ payload={"email": str(email)},
+ )
+ else:
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INVALID_MAGIC_CODE_SIGN_UP"
+ ],
+ error_message="INVALID_MAGIC_CODE_SIGN_UP",
+ payload={"email": str(email)},
+ )
+ else:
+ email = str(self.key).replace("magic_", "", 1)
+ if User.objects.filter(email=email).exists():
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "EXPIRED_MAGIC_CODE_SIGN_IN"
+ ],
+ error_message="EXPIRED_MAGIC_CODE_SIGN_IN",
+ payload={"email": str(email)},
+ )
+ else:
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "EXPIRED_MAGIC_CODE_SIGN_UP"
+ ],
+ error_message="EXPIRED_MAGIC_CODE_SIGN_UP",
+ payload={"email": str(email)},
+ )
diff --git a/apiserver/plane/authentication/provider/oauth/__init__.py b/apiserver/plane/authentication/provider/oauth/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/apiserver/plane/authentication/provider/oauth/github.py b/apiserver/plane/authentication/provider/oauth/github.py
new file mode 100644
index 000000000..798863d8f
--- /dev/null
+++ b/apiserver/plane/authentication/provider/oauth/github.py
@@ -0,0 +1,136 @@
+# Python imports
+import os
+from datetime import datetime
+from urllib.parse import urlencode
+
+import pytz
+import requests
+
+# Module imports
+from plane.authentication.adapter.oauth import OauthAdapter
+from plane.license.utils.instance_value import get_configuration_value
+from plane.authentication.adapter.error import (
+ AuthenticationException,
+ AUTHENTICATION_ERROR_CODES,
+)
+
+
+class GitHubOAuthProvider(OauthAdapter):
+
+ token_url = "https://github.com/login/oauth/access_token"
+ userinfo_url = "https://api.github.com/user"
+ provider = "github"
+ scope = "read:user user:email"
+
+ def __init__(self, request, code=None, state=None, callback=None):
+
+ GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET = get_configuration_value(
+ [
+ {
+ "key": "GITHUB_CLIENT_ID",
+ "default": os.environ.get("GITHUB_CLIENT_ID"),
+ },
+ {
+ "key": "GITHUB_CLIENT_SECRET",
+ "default": os.environ.get("GITHUB_CLIENT_SECRET"),
+ },
+ ]
+ )
+
+ if not (GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET):
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["GITHUB_NOT_CONFIGURED"],
+ error_message="GITHUB_NOT_CONFIGURED",
+ )
+
+ client_id = GITHUB_CLIENT_ID
+ client_secret = GITHUB_CLIENT_SECRET
+
+ redirect_uri = f"""{"https" if request.is_secure() else "http"}://{request.get_host()}/auth/github/callback/"""
+ url_params = {
+ "client_id": client_id,
+ "redirect_uri": redirect_uri,
+ "scope": self.scope,
+ "state": state,
+ }
+ auth_url = (
+ f"https://github.com/login/oauth/authorize?{urlencode(url_params)}"
+ )
+ super().__init__(
+ request,
+ self.provider,
+ client_id,
+ self.scope,
+ redirect_uri,
+ auth_url,
+ self.token_url,
+ self.userinfo_url,
+ client_secret,
+ code,
+ callback=callback,
+ )
+
+ def set_token_data(self):
+ data = {
+ "client_id": self.client_id,
+ "client_secret": self.client_secret,
+ "code": self.code,
+ "redirect_uri": self.redirect_uri,
+ }
+ token_response = self.get_user_token(
+ data=data, headers={"Accept": "application/json"}
+ )
+ super().set_token_data(
+ {
+ "access_token": token_response.get("access_token"),
+ "refresh_token": token_response.get("refresh_token", None),
+ "access_token_expired_at": (
+ datetime.fromtimestamp(
+ token_response.get("expires_in"),
+ tz=pytz.utc,
+ )
+ if token_response.get("expires_in")
+ else None
+ ),
+ "refresh_token_expired_at": (
+ datetime.fromtimestamp(
+ token_response.get("refresh_token_expired_at"),
+ tz=pytz.utc,
+ )
+ if token_response.get("refresh_token_expired_at")
+ else None
+ ),
+ "id_token": token_response.get("id_token", ""),
+ }
+ )
+
+ def __get_email(self, headers):
+ # Github does not provide email in user response
+ emails_url = "https://api.github.com/user/emails"
+ emails_response = requests.get(emails_url, headers=headers).json()
+ email = next(
+ (email["email"] for email in emails_response if email["primary"]),
+ None,
+ )
+ return email
+
+ def set_user_data(self):
+ user_info_response = self.get_user_response()
+ headers = {
+ "Authorization": f"Bearer {self.token_data.get('access_token')}",
+ "Accept": "application/json",
+ }
+ email = self.__get_email(headers=headers)
+ super().set_user_data(
+ {
+ "email": email,
+ "user": {
+ "provider_id": user_info_response.get("id"),
+ "email": email,
+ "avatar": user_info_response.get("avatar_url"),
+ "first_name": user_info_response.get("name"),
+ "last_name": user_info_response.get("family_name"),
+ "is_password_autoset": True,
+ },
+ }
+ )
diff --git a/apiserver/plane/authentication/provider/oauth/google.py b/apiserver/plane/authentication/provider/oauth/google.py
new file mode 100644
index 000000000..9c17a75af
--- /dev/null
+++ b/apiserver/plane/authentication/provider/oauth/google.py
@@ -0,0 +1,117 @@
+# Python imports
+import os
+from datetime import datetime
+from urllib.parse import urlencode
+
+import pytz
+
+# Module imports
+from plane.authentication.adapter.oauth import OauthAdapter
+from plane.license.utils.instance_value import get_configuration_value
+from plane.authentication.adapter.error import (
+ AUTHENTICATION_ERROR_CODES,
+ AuthenticationException,
+)
+
+
+class GoogleOAuthProvider(OauthAdapter):
+ token_url = "https://oauth2.googleapis.com/token"
+ userinfo_url = "https://www.googleapis.com/oauth2/v2/userinfo"
+ scope = "https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"
+ provider = "google"
+
+ def __init__(self, request, code=None, state=None, callback=None):
+ (GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET) = get_configuration_value(
+ [
+ {
+ "key": "GOOGLE_CLIENT_ID",
+ "default": os.environ.get("GOOGLE_CLIENT_ID"),
+ },
+ {
+ "key": "GOOGLE_CLIENT_SECRET",
+ "default": os.environ.get("GOOGLE_CLIENT_SECRET"),
+ },
+ ]
+ )
+
+ if not (GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET):
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["GOOGLE_NOT_CONFIGURED"],
+ error_message="GOOGLE_NOT_CONFIGURED",
+ )
+
+ client_id = GOOGLE_CLIENT_ID
+ client_secret = GOOGLE_CLIENT_SECRET
+
+ redirect_uri = f"""{"https" if request.is_secure() else "http"}://{request.get_host()}/auth/google/callback/"""
+ url_params = {
+ "client_id": client_id,
+ "scope": self.scope,
+ "redirect_uri": redirect_uri,
+ "response_type": "code",
+ "access_type": "offline",
+ "prompt": "consent",
+ "state": state,
+ }
+ auth_url = f"https://accounts.google.com/o/oauth2/v2/auth?{urlencode(url_params)}"
+
+ super().__init__(
+ request,
+ self.provider,
+ client_id,
+ self.scope,
+ redirect_uri,
+ auth_url,
+ self.token_url,
+ self.userinfo_url,
+ client_secret,
+ code,
+ callback=callback,
+ )
+
+ def set_token_data(self):
+ data = {
+ "code": self.code,
+ "client_id": self.client_id,
+ "client_secret": self.client_secret,
+ "redirect_uri": self.redirect_uri,
+ "grant_type": "authorization_code",
+ }
+ token_response = self.get_user_token(data=data)
+ super().set_token_data(
+ {
+ "access_token": token_response.get("access_token"),
+ "refresh_token": token_response.get("refresh_token", None),
+ "access_token_expired_at": (
+ datetime.fromtimestamp(
+ token_response.get("expires_in"),
+ tz=pytz.utc,
+ )
+ if token_response.get("expires_in")
+ else None
+ ),
+ "refresh_token_expired_at": (
+ datetime.fromtimestamp(
+ token_response.get("refresh_token_expired_at"),
+ tz=pytz.utc,
+ )
+ if token_response.get("refresh_token_expired_at")
+ else None
+ ),
+ "id_token": token_response.get("id_token", ""),
+ }
+ )
+
+ def set_user_data(self):
+ user_info_response = self.get_user_response()
+ user_data = {
+ "email": user_info_response.get("email"),
+ "user": {
+ "avatar": user_info_response.get("picture"),
+ "first_name": user_info_response.get("given_name"),
+ "last_name": user_info_response.get("family_name"),
+ "provider_id": user_info_response.get("id"),
+ "is_password_autoset": True,
+ },
+ }
+ super().set_user_data(user_data)
diff --git a/apiserver/plane/authentication/rate_limit.py b/apiserver/plane/authentication/rate_limit.py
new file mode 100644
index 000000000..744bd38fe
--- /dev/null
+++ b/apiserver/plane/authentication/rate_limit.py
@@ -0,0 +1,26 @@
+# Third party imports
+from rest_framework.throttling import AnonRateThrottle
+from rest_framework import status
+from rest_framework.response import Response
+
+# Module imports
+from plane.authentication.adapter.error import (
+ AuthenticationException,
+ AUTHENTICATION_ERROR_CODES,
+)
+
+
+class AuthenticationThrottle(AnonRateThrottle):
+ rate = "30/minute"
+ scope = "authentication"
+
+ def throttle_failure_view(self, request, *args, **kwargs):
+ try:
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["RATE_LIMIT_EXCEEDED"],
+ error_message="RATE_LIMIT_EXCEEDED",
+ )
+ except AuthenticationException as e:
+ return Response(
+ e.get_error_dict(), status=status.HTTP_429_TOO_MANY_REQUESTS
+ )
diff --git a/apiserver/plane/authentication/session.py b/apiserver/plane/authentication/session.py
new file mode 100644
index 000000000..7bb0b4a00
--- /dev/null
+++ b/apiserver/plane/authentication/session.py
@@ -0,0 +1,8 @@
+from rest_framework.authentication import SessionAuthentication
+
+
+class BaseSessionAuthentication(SessionAuthentication):
+
+ # Disable csrf for the rest apis
+ def enforce_csrf(self, request):
+ return
diff --git a/apiserver/plane/authentication/urls.py b/apiserver/plane/authentication/urls.py
new file mode 100644
index 000000000..ee860f41f
--- /dev/null
+++ b/apiserver/plane/authentication/urls.py
@@ -0,0 +1,196 @@
+from django.urls import path
+
+from .views import (
+ CSRFTokenEndpoint,
+ ForgotPasswordEndpoint,
+ SetUserPasswordEndpoint,
+ ResetPasswordEndpoint,
+ ChangePasswordEndpoint,
+ # App
+ EmailCheckEndpoint,
+ GitHubCallbackEndpoint,
+ GitHubOauthInitiateEndpoint,
+ GoogleCallbackEndpoint,
+ GoogleOauthInitiateEndpoint,
+ MagicGenerateEndpoint,
+ MagicSignInEndpoint,
+ MagicSignUpEndpoint,
+ SignInAuthEndpoint,
+ SignOutAuthEndpoint,
+ SignUpAuthEndpoint,
+ ForgotPasswordSpaceEndpoint,
+ ResetPasswordSpaceEndpoint,
+ # Space
+ EmailCheckSpaceEndpoint,
+ GitHubCallbackSpaceEndpoint,
+ GitHubOauthInitiateSpaceEndpoint,
+ GoogleCallbackSpaceEndpoint,
+ GoogleOauthInitiateSpaceEndpoint,
+ MagicGenerateSpaceEndpoint,
+ MagicSignInSpaceEndpoint,
+ MagicSignUpSpaceEndpoint,
+ SignInAuthSpaceEndpoint,
+ SignUpAuthSpaceEndpoint,
+ SignOutAuthSpaceEndpoint,
+)
+
+urlpatterns = [
+ # credentials
+ path(
+ "sign-in/",
+ SignInAuthEndpoint.as_view(),
+ name="sign-in",
+ ),
+ path(
+ "sign-up/",
+ SignUpAuthEndpoint.as_view(),
+ name="sign-up",
+ ),
+ path(
+ "spaces/sign-in/",
+ SignInAuthSpaceEndpoint.as_view(),
+ name="sign-in",
+ ),
+ path(
+ "spaces/sign-up/",
+ SignUpAuthSpaceEndpoint.as_view(),
+ name="sign-in",
+ ),
+ # signout
+ path(
+ "sign-out/",
+ SignOutAuthEndpoint.as_view(),
+ name="sign-out",
+ ),
+ path(
+ "spaces/sign-out/",
+ SignOutAuthSpaceEndpoint.as_view(),
+ name="sign-out",
+ ),
+ # csrf token
+ path(
+ "get-csrf-token/",
+ CSRFTokenEndpoint.as_view(),
+ name="get_csrf_token",
+ ),
+ # Magic sign in
+ path(
+ "magic-generate/",
+ MagicGenerateEndpoint.as_view(),
+ name="magic-generate",
+ ),
+ path(
+ "magic-sign-in/",
+ MagicSignInEndpoint.as_view(),
+ name="magic-sign-in",
+ ),
+ path(
+ "magic-sign-up/",
+ MagicSignUpEndpoint.as_view(),
+ name="magic-sign-up",
+ ),
+ path(
+ "get-csrf-token/",
+ CSRFTokenEndpoint.as_view(),
+ name="get_csrf_token",
+ ),
+ path(
+ "spaces/magic-generate/",
+ MagicGenerateSpaceEndpoint.as_view(),
+ name="magic-generate",
+ ),
+ path(
+ "spaces/magic-sign-in/",
+ MagicSignInSpaceEndpoint.as_view(),
+ name="magic-sign-in",
+ ),
+ path(
+ "spaces/magic-sign-up/",
+ MagicSignUpSpaceEndpoint.as_view(),
+ name="magic-sign-up",
+ ),
+ ## Google Oauth
+ path(
+ "google/",
+ GoogleOauthInitiateEndpoint.as_view(),
+ name="google-initiate",
+ ),
+ path(
+ "google/callback/",
+ GoogleCallbackEndpoint.as_view(),
+ name="google-callback",
+ ),
+ path(
+ "spaces/google/",
+ GoogleOauthInitiateSpaceEndpoint.as_view(),
+ name="google-initiate",
+ ),
+ path(
+ "google/callback/",
+ GoogleCallbackSpaceEndpoint.as_view(),
+ name="google-callback",
+ ),
+ ## Github Oauth
+ path(
+ "github/",
+ GitHubOauthInitiateEndpoint.as_view(),
+ name="github-initiate",
+ ),
+ path(
+ "github/callback/",
+ GitHubCallbackEndpoint.as_view(),
+ name="github-callback",
+ ),
+ path(
+ "spaces/github/",
+ GitHubOauthInitiateSpaceEndpoint.as_view(),
+ name="github-initiate",
+ ),
+ path(
+ "spaces/github/callback/",
+ GitHubCallbackSpaceEndpoint.as_view(),
+ name="github-callback",
+ ),
+ # Email Check
+ path(
+ "email-check/",
+ EmailCheckEndpoint.as_view(),
+ name="email-check",
+ ),
+ path(
+ "spaces/email-check/",
+ EmailCheckSpaceEndpoint.as_view(),
+ name="email-check",
+ ),
+ # Password
+ path(
+ "forgot-password/",
+ ForgotPasswordEndpoint.as_view(),
+ name="forgot-password",
+ ),
+ path(
+ "reset-password///",
+ ResetPasswordEndpoint.as_view(),
+ name="forgot-password",
+ ),
+ path(
+ "spaces/forgot-password/",
+ ForgotPasswordSpaceEndpoint.as_view(),
+ name="forgot-password",
+ ),
+ path(
+ "spaces/reset-password///",
+ ResetPasswordSpaceEndpoint.as_view(),
+ name="forgot-password",
+ ),
+ path(
+ "change-password/",
+ ChangePasswordEndpoint.as_view(),
+ name="forgot-password",
+ ),
+ path(
+ "set-password/",
+ SetUserPasswordEndpoint.as_view(),
+ name="set-password",
+ ),
+]
diff --git a/apiserver/plane/authentication/utils/host.py b/apiserver/plane/authentication/utils/host.py
new file mode 100644
index 000000000..4046c1e20
--- /dev/null
+++ b/apiserver/plane/authentication/utils/host.py
@@ -0,0 +1,42 @@
+# Python imports
+from urllib.parse import urlsplit
+
+# Django imports
+from django.conf import settings
+
+
+def base_host(request, is_admin=False, is_space=False, is_app=False):
+ """Utility function to return host / origin from the request"""
+ # Calculate the base origin from request
+ base_origin = str(
+ request.META.get("HTTP_ORIGIN")
+ or f"{urlsplit(request.META.get('HTTP_REFERER')).scheme}://{urlsplit(request.META.get('HTTP_REFERER')).netloc}"
+ or f"""{"https" if request.is_secure() else "http"}://{request.get_host()}"""
+ )
+
+ # Admin redirections
+ if is_admin:
+ if settings.ADMIN_BASE_URL:
+ return settings.ADMIN_BASE_URL
+ else:
+ return base_origin + "/god-mode/"
+
+ # Space redirections
+ if is_space:
+ if settings.SPACE_BASE_URL:
+ return settings.SPACE_BASE_URL
+ else:
+ return base_origin + "/spaces/"
+
+ # App Redirection
+ if is_app:
+ if settings.APP_BASE_URL:
+ return settings.APP_BASE_URL
+ else:
+ return base_origin
+
+ return base_origin
+
+
+def user_ip(request):
+ return str(request.META.get("REMOTE_ADDR"))
diff --git a/apiserver/plane/authentication/utils/login.py b/apiserver/plane/authentication/utils/login.py
new file mode 100644
index 000000000..f5d453d02
--- /dev/null
+++ b/apiserver/plane/authentication/utils/login.py
@@ -0,0 +1,28 @@
+# Django imports
+from django.contrib.auth import login
+from django.conf import settings
+
+# Module imports
+from plane.authentication.utils.host import base_host
+
+
+def user_login(request, user, is_app=False, is_admin=False, is_space=False):
+ login(request=request, user=user)
+
+ # If is admin cookie set the custom age
+ if is_admin:
+ request.session.set_expiry(settings.ADMIN_SESSION_COOKIE_AGE)
+
+ device_info = {
+ "user_agent": request.META.get("HTTP_USER_AGENT", ""),
+ "ip_address": request.META.get("REMOTE_ADDR", ""),
+ "domain": base_host(
+ request=request,
+ is_app=is_app,
+ is_admin=is_admin,
+ is_space=is_space,
+ ),
+ }
+ request.session["device_info"] = device_info
+ request.session.save()
+ return
diff --git a/apiserver/plane/authentication/utils/redirection_path.py b/apiserver/plane/authentication/utils/redirection_path.py
new file mode 100644
index 000000000..12de25cc2
--- /dev/null
+++ b/apiserver/plane/authentication/utils/redirection_path.py
@@ -0,0 +1,45 @@
+from plane.db.models import Profile, Workspace, WorkspaceMemberInvite
+
+
+def get_redirection_path(user):
+ # Handle redirections
+ profile = Profile.objects.get(user=user)
+
+ # Redirect to onboarding if the user is not onboarded yet
+ if not profile.is_onboarded:
+ return "onboarding"
+
+ # Redirect to the last workspace if the user has last workspace
+ if (
+ profile.last_workspace_id
+ and Workspace.objects.filter(
+ pk=profile.last_workspace_id,
+ workspace_member__member_id=user.id,
+ workspace_member__is_active=True,
+ ).exists()
+ ):
+ workspace = Workspace.objects.filter(
+ pk=profile.last_workspace_id,
+ workspace_member__member_id=user.id,
+ workspace_member__is_active=True,
+ ).first()
+ return f"{workspace.slug}"
+
+ fallback_workspace = (
+ Workspace.objects.filter(
+ workspace_member__member_id=user.id,
+ workspace_member__is_active=True,
+ )
+ .order_by("created_at")
+ .first()
+ )
+ # Redirect to fallback workspace
+ if fallback_workspace:
+ return f"{fallback_workspace.slug}"
+
+ # Redirect to invitations if the user has unaccepted invitations
+ if WorkspaceMemberInvite.objects.filter(email=user.email).count():
+ return "invitations"
+
+ # Redirect the user to create workspace
+ return "create-workspace"
diff --git a/apiserver/plane/authentication/utils/user_auth_workflow.py b/apiserver/plane/authentication/utils/user_auth_workflow.py
new file mode 100644
index 000000000..e7cb4942e
--- /dev/null
+++ b/apiserver/plane/authentication/utils/user_auth_workflow.py
@@ -0,0 +1,9 @@
+from .workspace_project_join import process_workspace_project_invitations
+
+
+def post_user_auth_workflow(
+ user,
+ is_signup,
+ request,
+):
+ process_workspace_project_invitations(user=user)
diff --git a/apiserver/plane/authentication/utils/workspace_project_join.py b/apiserver/plane/authentication/utils/workspace_project_join.py
new file mode 100644
index 000000000..8910ec637
--- /dev/null
+++ b/apiserver/plane/authentication/utils/workspace_project_join.py
@@ -0,0 +1,72 @@
+from plane.db.models import (
+ ProjectMember,
+ ProjectMemberInvite,
+ WorkspaceMember,
+ WorkspaceMemberInvite,
+)
+
+
+def process_workspace_project_invitations(user):
+ """This function takes in User and adds him to all workspace and projects that the user has accepted invited of"""
+
+ # Check if user has any accepted invites for workspace and add them to workspace
+ workspace_member_invites = WorkspaceMemberInvite.objects.filter(
+ email=user.email, accepted=True
+ )
+
+ WorkspaceMember.objects.bulk_create(
+ [
+ WorkspaceMember(
+ workspace_id=workspace_member_invite.workspace_id,
+ member=user,
+ role=workspace_member_invite.role,
+ )
+ for workspace_member_invite in workspace_member_invites
+ ],
+ ignore_conflicts=True,
+ )
+
+ # Check if user has any project invites
+ project_member_invites = ProjectMemberInvite.objects.filter(
+ email=user.email, accepted=True
+ )
+
+ # Add user to workspace
+ WorkspaceMember.objects.bulk_create(
+ [
+ WorkspaceMember(
+ workspace_id=project_member_invite.workspace_id,
+ role=(
+ project_member_invite.role
+ if project_member_invite.role in [5, 10, 15]
+ else 15
+ ),
+ member=user,
+ created_by_id=project_member_invite.created_by_id,
+ )
+ for project_member_invite in project_member_invites
+ ],
+ ignore_conflicts=True,
+ )
+
+ # Now add the users to project
+ ProjectMember.objects.bulk_create(
+ [
+ ProjectMember(
+ workspace_id=project_member_invite.workspace_id,
+ role=(
+ project_member_invite.role
+ if project_member_invite.role in [5, 10, 15]
+ else 15
+ ),
+ member=user,
+ created_by_id=project_member_invite.created_by_id,
+ )
+ for project_member_invite in project_member_invites
+ ],
+ ignore_conflicts=True,
+ )
+
+ # Delete all the invites
+ workspace_member_invites.delete()
+ project_member_invites.delete()
diff --git a/apiserver/plane/authentication/views/__init__.py b/apiserver/plane/authentication/views/__init__.py
new file mode 100644
index 000000000..51ea3e60a
--- /dev/null
+++ b/apiserver/plane/authentication/views/__init__.py
@@ -0,0 +1,59 @@
+from .common import (
+ ChangePasswordEndpoint,
+ CSRFTokenEndpoint,
+ SetUserPasswordEndpoint,
+)
+
+from .app.check import EmailCheckEndpoint
+
+from .app.email import (
+ SignInAuthEndpoint,
+ SignUpAuthEndpoint,
+)
+from .app.github import (
+ GitHubCallbackEndpoint,
+ GitHubOauthInitiateEndpoint,
+)
+from .app.google import (
+ GoogleCallbackEndpoint,
+ GoogleOauthInitiateEndpoint,
+)
+from .app.magic import (
+ MagicGenerateEndpoint,
+ MagicSignInEndpoint,
+ MagicSignUpEndpoint,
+)
+
+from .app.signout import SignOutAuthEndpoint
+
+
+from .space.email import SignInAuthSpaceEndpoint, SignUpAuthSpaceEndpoint
+
+from .space.github import (
+ GitHubCallbackSpaceEndpoint,
+ GitHubOauthInitiateSpaceEndpoint,
+)
+
+from .space.google import (
+ GoogleCallbackSpaceEndpoint,
+ GoogleOauthInitiateSpaceEndpoint,
+)
+
+from .space.magic import (
+ MagicGenerateSpaceEndpoint,
+ MagicSignInSpaceEndpoint,
+ MagicSignUpSpaceEndpoint,
+)
+
+from .space.signout import SignOutAuthSpaceEndpoint
+
+from .space.check import EmailCheckSpaceEndpoint
+
+from .space.password_management import (
+ ForgotPasswordSpaceEndpoint,
+ ResetPasswordSpaceEndpoint,
+)
+from .app.password_management import (
+ ForgotPasswordEndpoint,
+ ResetPasswordEndpoint,
+)
diff --git a/apiserver/plane/authentication/views/app/check.py b/apiserver/plane/authentication/views/app/check.py
new file mode 100644
index 000000000..5b3ac7337
--- /dev/null
+++ b/apiserver/plane/authentication/views/app/check.py
@@ -0,0 +1,133 @@
+# Python imports
+import os
+
+# Django imports
+from django.core.validators import validate_email
+from django.core.exceptions import ValidationError
+
+# Third party imports
+from rest_framework import status
+from rest_framework.permissions import AllowAny
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
+## Module imports
+from plane.db.models import User
+from plane.license.models import Instance
+from plane.authentication.adapter.error import (
+ AuthenticationException,
+ AUTHENTICATION_ERROR_CODES,
+)
+from plane.authentication.rate_limit import AuthenticationThrottle
+from plane.license.utils.instance_value import (
+ get_configuration_value,
+)
+
+
+class EmailCheckEndpoint(APIView):
+
+ permission_classes = [
+ AllowAny,
+ ]
+
+ throttle_classes = [
+ AuthenticationThrottle,
+ ]
+
+ def post(self, request):
+ # Check instance configuration
+ instance = Instance.objects.first()
+ if instance is None or not instance.is_setup_done:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INSTANCE_NOT_CONFIGURED"
+ ],
+ error_message="INSTANCE_NOT_CONFIGURED",
+ )
+ return Response(
+ exc.get_error_dict(),
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ (EMAIL_HOST, ENABLE_MAGIC_LINK_LOGIN) = get_configuration_value(
+ [
+ {
+ "key": "EMAIL_HOST",
+ "default": os.environ.get("EMAIL_HOST", ""),
+ },
+ {
+ "key": "ENABLE_MAGIC_LINK_LOGIN",
+ "default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "1"),
+ },
+ ]
+ )
+
+ smtp_configured = bool(EMAIL_HOST)
+ is_magic_login_enabled = ENABLE_MAGIC_LINK_LOGIN == "1"
+
+ email = request.data.get("email", False)
+
+ # Return error if email is not present
+ if not email:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["EMAIL_REQUIRED"],
+ error_message="EMAIL_REQUIRED",
+ )
+ return Response(
+ exc.get_error_dict(),
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # Validate email
+ try:
+ validate_email(email)
+ except ValidationError:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
+ error_message="INVALID_EMAIL",
+ )
+ return Response(
+ exc.get_error_dict(),
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ # Check if a user already exists with the given email
+ existing_user = User.objects.filter(email=email).first()
+
+ # If existing user
+ if existing_user:
+ if not existing_user.is_active:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "USER_ACCOUNT_DEACTIVATED"
+ ],
+ error_message="USER_ACCOUNT_DEACTIVATED",
+ )
+ return Response(
+ exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST
+ )
+
+ return Response(
+ {
+ "existing": True,
+ "status": (
+ "MAGIC_CODE"
+ if existing_user.is_password_autoset
+ and smtp_configured
+ and is_magic_login_enabled
+ else "CREDENTIAL"
+ ),
+ },
+ status=status.HTTP_200_OK,
+ )
+ # Else return response
+ return Response(
+ {
+ "existing": False,
+ "status": (
+ "MAGIC_CODE"
+ if smtp_configured and is_magic_login_enabled
+ else "CREDENTIAL"
+ ),
+ },
+ status=status.HTTP_200_OK,
+ )
diff --git a/apiserver/plane/authentication/views/app/email.py b/apiserver/plane/authentication/views/app/email.py
new file mode 100644
index 000000000..f21e431a4
--- /dev/null
+++ b/apiserver/plane/authentication/views/app/email.py
@@ -0,0 +1,282 @@
+# Python imports
+from urllib.parse import urlencode, urljoin
+
+# Django imports
+from django.core.exceptions import ValidationError
+from django.core.validators import validate_email
+from django.http import HttpResponseRedirect
+from django.views import View
+
+# Module imports
+from plane.authentication.provider.credentials.email import EmailProvider
+from plane.authentication.utils.login import user_login
+from plane.license.models import Instance
+from plane.authentication.utils.host import base_host
+from plane.authentication.utils.redirection_path import get_redirection_path
+from plane.authentication.utils.user_auth_workflow import (
+ post_user_auth_workflow,
+)
+from plane.db.models import User
+from plane.authentication.adapter.error import (
+ AuthenticationException,
+ AUTHENTICATION_ERROR_CODES,
+)
+
+
+class SignInAuthEndpoint(View):
+
+ def post(self, request):
+ next_path = request.POST.get("next_path")
+ # Check instance configuration
+ instance = Instance.objects.first()
+ if instance is None or not instance.is_setup_done:
+ # Redirection params
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INSTANCE_NOT_CONFIGURED"
+ ],
+ error_message="INSTANCE_NOT_CONFIGURED",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ # Base URL join
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "sign-in?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
+
+ # set the referer as session to redirect after login
+ email = request.POST.get("email", False)
+ password = request.POST.get("password", False)
+
+ ## Raise exception if any of the above are missing
+ if not email or not password:
+ # Redirection params
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "REQUIRED_EMAIL_PASSWORD_SIGN_IN"
+ ],
+ error_message="REQUIRED_EMAIL_PASSWORD_SIGN_IN",
+ payload={"email": str(email)},
+ )
+ params = exc.get_error_dict()
+ # Next path
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "sign-in?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
+
+ # Validate email
+ email = email.strip().lower()
+ try:
+ validate_email(email)
+ except ValidationError:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL_SIGN_IN"],
+ error_message="INVALID_EMAIL_SIGN_IN",
+ payload={"email": str(email)},
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "sign-in?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
+
+ existing_user = User.objects.filter(email=email).first()
+
+ if not existing_user:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"],
+ error_message="USER_DOES_NOT_EXIST",
+ payload={"email": str(email)},
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "sign-in?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
+
+ if not existing_user.is_active:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "USER_ACCOUNT_DEACTIVATED"
+ ],
+ error_message="USER_ACCOUNT_DEACTIVATED",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "sign-in?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
+
+ try:
+ provider = EmailProvider(
+ request=request,
+ key=email,
+ code=password,
+ is_signup=False,
+ callback=post_user_auth_workflow,
+ )
+ user = provider.authenticate()
+ # Login the user and record his device info
+ user_login(request=request, user=user, is_app=True)
+ # Get the redirection path
+ if next_path:
+ path = str(next_path)
+ else:
+ path = get_redirection_path(user=user)
+
+ # redirect to referer path
+ url = urljoin(base_host(request=request, is_app=True), path)
+ return HttpResponseRedirect(url)
+ except AuthenticationException as e:
+ params = e.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "sign-in?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
+
+
+class SignUpAuthEndpoint(View):
+
+ def post(self, request):
+ next_path = request.POST.get("next_path")
+ # Check instance configuration
+ instance = Instance.objects.first()
+ if instance is None or not instance.is_setup_done:
+ # Redirection params
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INSTANCE_NOT_CONFIGURED"
+ ],
+ error_message="INSTANCE_NOT_CONFIGURED",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
+
+ email = request.POST.get("email", False)
+ password = request.POST.get("password", False)
+ ## Raise exception if any of the above are missing
+ if not email or not password:
+ # Redirection params
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "REQUIRED_EMAIL_PASSWORD_SIGN_UP"
+ ],
+ error_message="REQUIRED_EMAIL_PASSWORD_SIGN_UP",
+ payload={"email": str(email)},
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
+ # Validate the email
+ email = email.strip().lower()
+ try:
+ validate_email(email)
+ except ValidationError:
+ # Redirection params
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL_SIGN_UP"],
+ error_message="INVALID_EMAIL_SIGN_UP",
+ payload={"email": str(email)},
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
+
+ # Existing user
+ existing_user = User.objects.filter(email=email).first()
+
+ if existing_user:
+ # Existing User
+ if not existing_user.is_active:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "USER_ACCOUNT_DEACTIVATED"
+ ],
+ error_message="USER_ACCOUNT_DEACTIVATED",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
+
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"],
+ error_message="USER_ALREADY_EXIST",
+ payload={"email": str(email)},
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
+
+ try:
+ provider = EmailProvider(
+ request=request,
+ key=email,
+ code=password,
+ is_signup=True,
+ callback=post_user_auth_workflow,
+ )
+ user = provider.authenticate()
+ # Login the user and record his device info
+ user_login(request=request, user=user, is_app=True)
+ # Get the redirection path
+ if next_path:
+ path = next_path
+ else:
+ path = get_redirection_path(user=user)
+ # redirect to referer path
+ url = urljoin(base_host(request=request, is_app=True), path)
+ return HttpResponseRedirect(url)
+ except AuthenticationException as e:
+ params = e.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
diff --git a/apiserver/plane/authentication/views/app/github.py b/apiserver/plane/authentication/views/app/github.py
new file mode 100644
index 000000000..f93beefa3
--- /dev/null
+++ b/apiserver/plane/authentication/views/app/github.py
@@ -0,0 +1,131 @@
+import uuid
+from urllib.parse import urlencode, urljoin
+
+# Django import
+from django.http import HttpResponseRedirect
+from django.views import View
+
+# Module imports
+from plane.authentication.provider.oauth.github import GitHubOAuthProvider
+from plane.authentication.utils.login import user_login
+from plane.authentication.utils.redirection_path import get_redirection_path
+from plane.authentication.utils.user_auth_workflow import (
+ post_user_auth_workflow,
+)
+from plane.license.models import Instance
+from plane.authentication.utils.host import base_host
+from plane.authentication.adapter.error import (
+ AuthenticationException,
+ AUTHENTICATION_ERROR_CODES,
+)
+
+
+class GitHubOauthInitiateEndpoint(View):
+
+ def get(self, request):
+ # Get host and next path
+ request.session["host"] = base_host(request=request, is_app=True)
+ next_path = request.GET.get("next_path")
+ if next_path:
+ request.session["next_path"] = str(next_path)
+
+ # Check instance configuration
+ instance = Instance.objects.first()
+ if instance is None or not instance.is_setup_done:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INSTANCE_NOT_CONFIGURED"
+ ],
+ error_message="INSTANCE_NOT_CONFIGURED",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
+ try:
+ state = uuid.uuid4().hex
+ provider = GitHubOAuthProvider(request=request, state=state)
+ request.session["state"] = state
+ auth_url = provider.get_auth_url()
+ return HttpResponseRedirect(auth_url)
+ except AuthenticationException as e:
+ params = e.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
+
+
+class GitHubCallbackEndpoint(View):
+
+ def get(self, request):
+ code = request.GET.get("code")
+ state = request.GET.get("state")
+ base_host = request.session.get("host")
+ next_path = request.session.get("next_path")
+
+ if state != request.session.get("state", ""):
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "GITHUB_OAUTH_PROVIDER_ERROR"
+ ],
+ error_message="GITHUB_OAUTH_PROVIDER_ERROR",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = urljoin(
+ base_host,
+ "?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
+
+ if not code:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "GITHUB_OAUTH_PROVIDER_ERROR"
+ ],
+ error_message="GITHUB_OAUTH_PROVIDER_ERROR",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = urljoin(
+ base_host,
+ "?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
+
+ try:
+ provider = GitHubOAuthProvider(
+ request=request,
+ code=code,
+ callback=post_user_auth_workflow,
+ )
+ user = provider.authenticate()
+ # Login the user and record his device info
+ user_login(request=request, user=user, is_app=True)
+ # Get the redirection path
+ if next_path:
+ path = next_path
+ else:
+ path = get_redirection_path(user=user)
+ # redirect to referer path
+ url = urljoin(base_host, path)
+ return HttpResponseRedirect(url)
+ except AuthenticationException as e:
+ params = e.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = urljoin(
+ base_host,
+ "?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
diff --git a/apiserver/plane/authentication/views/app/google.py b/apiserver/plane/authentication/views/app/google.py
new file mode 100644
index 000000000..05f4511e2
--- /dev/null
+++ b/apiserver/plane/authentication/views/app/google.py
@@ -0,0 +1,126 @@
+# Python imports
+import uuid
+from urllib.parse import urlencode, urljoin
+
+# Django import
+from django.http import HttpResponseRedirect
+from django.views import View
+
+
+# Module imports
+from plane.authentication.provider.oauth.google import GoogleOAuthProvider
+from plane.authentication.utils.login import user_login
+from plane.authentication.utils.redirection_path import get_redirection_path
+from plane.authentication.utils.user_auth_workflow import (
+ post_user_auth_workflow,
+)
+from plane.license.models import Instance
+from plane.authentication.utils.host import base_host
+from plane.authentication.adapter.error import (
+ AuthenticationException,
+ AUTHENTICATION_ERROR_CODES,
+)
+
+
+class GoogleOauthInitiateEndpoint(View):
+ def get(self, request):
+ request.session["host"] = base_host(request=request, is_app=True)
+ next_path = request.GET.get("next_path")
+ if next_path:
+ request.session["next_path"] = str(next_path)
+
+ # Check instance configuration
+ instance = Instance.objects.first()
+ if instance is None or not instance.is_setup_done:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INSTANCE_NOT_CONFIGURED"
+ ],
+ error_message="INSTANCE_NOT_CONFIGURED",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
+
+ try:
+ state = uuid.uuid4().hex
+ provider = GoogleOAuthProvider(request=request, state=state)
+ request.session["state"] = state
+ auth_url = provider.get_auth_url()
+ return HttpResponseRedirect(auth_url)
+ except AuthenticationException as e:
+ params = e.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
+
+
+class GoogleCallbackEndpoint(View):
+ def get(self, request):
+ code = request.GET.get("code")
+ state = request.GET.get("state")
+ base_host = request.session.get("host")
+ next_path = request.session.get("next_path")
+
+ if state != request.session.get("state", ""):
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "GOOGLE_OAUTH_PROVIDER_ERROR"
+ ],
+ error_message="GOOGLE_OAUTH_PROVIDER_ERROR",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = urljoin(
+ base_host,
+ "?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
+ if not code:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "GOOGLE_OAUTH_PROVIDER_ERROR"
+ ],
+ error_message="GOOGLE_OAUTH_PROVIDER_ERROR",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = next_path
+ url = urljoin(
+ base_host,
+ "?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
+ try:
+ provider = GoogleOAuthProvider(
+ request=request,
+ code=code,
+ callback=post_user_auth_workflow,
+ )
+ user = provider.authenticate()
+ # Login the user and record his device info
+ user_login(request=request, user=user, is_app=True)
+ # Get the redirection path
+ path = get_redirection_path(user=user)
+ # redirect to referer path
+ url = urljoin(base_host, str(next_path) if next_path else path)
+ return HttpResponseRedirect(url)
+ except AuthenticationException as e:
+ params = e.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = urljoin(
+ base_host,
+ "?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
diff --git a/apiserver/plane/authentication/views/app/magic.py b/apiserver/plane/authentication/views/app/magic.py
new file mode 100644
index 000000000..bb3c72534
--- /dev/null
+++ b/apiserver/plane/authentication/views/app/magic.py
@@ -0,0 +1,233 @@
+# Python imports
+from urllib.parse import urlencode, urljoin
+
+# Django imports
+from django.core.validators import validate_email
+from django.http import HttpResponseRedirect
+from django.views import View
+
+# Third party imports
+from rest_framework import status
+from rest_framework.permissions import AllowAny
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
+# Module imports
+from plane.authentication.provider.credentials.magic_code import (
+ MagicCodeProvider,
+)
+from plane.authentication.utils.login import user_login
+from plane.authentication.utils.redirection_path import get_redirection_path
+from plane.authentication.utils.user_auth_workflow import (
+ post_user_auth_workflow,
+)
+from plane.bgtasks.magic_link_code_task import magic_link
+from plane.license.models import Instance
+from plane.authentication.utils.host import base_host
+from plane.db.models import User, Profile
+from plane.authentication.adapter.error import (
+ AuthenticationException,
+ AUTHENTICATION_ERROR_CODES,
+)
+
+
+class MagicGenerateEndpoint(APIView):
+
+ permission_classes = [
+ AllowAny,
+ ]
+
+ def post(self, request):
+ # Check if instance is configured
+ instance = Instance.objects.first()
+ if instance is None or not instance.is_setup_done:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INSTANCE_NOT_CONFIGURED"
+ ],
+ error_message="INSTANCE_NOT_CONFIGURED",
+ )
+ return Response(
+ exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST
+ )
+
+ origin = request.META.get("HTTP_ORIGIN", "/")
+ email = request.data.get("email", False)
+ try:
+ # Clean up the email
+ email = email.strip().lower()
+ validate_email(email)
+ adapter = MagicCodeProvider(request=request, key=email)
+ key, token = adapter.initiate()
+ # If the smtp is configured send through here
+ magic_link.delay(email, key, token, origin)
+ return Response({"key": str(key)}, status=status.HTTP_200_OK)
+ except AuthenticationException as e:
+ params = e.get_error_dict()
+ return Response(
+ params,
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+
+class MagicSignInEndpoint(View):
+
+ def post(self, request):
+
+ # set the referer as session to redirect after login
+ code = request.POST.get("code", "").strip()
+ email = request.POST.get("email", "").strip().lower()
+ next_path = request.POST.get("next_path")
+
+ if code == "" or email == "":
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED"
+ ],
+ error_message="MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "sign-in?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
+
+ # Existing User
+ existing_user = User.objects.filter(email=email).first()
+
+ if not existing_user:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"],
+ error_message="USER_DOES_NOT_EXIST",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "sign-in?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
+
+ if not existing_user.is_active:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "USER_ACCOUNT_DEACTIVATED"
+ ],
+ error_message="USER_ACCOUNT_DEACTIVATED",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "sign-in?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
+
+ try:
+ provider = MagicCodeProvider(
+ request=request,
+ key=f"magic_{email}",
+ code=code,
+ callback=post_user_auth_workflow,
+ )
+ user = provider.authenticate()
+ profile = Profile.objects.get(user=user)
+ # Login the user and record his device info
+ user_login(request=request, user=user, is_app=True)
+ if user.is_password_autoset and profile.is_onboarded:
+ path = "accounts/set-password"
+ else:
+ # Get the redirection path
+ path = (
+ str(next_path)
+ if next_path
+ else str(get_redirection_path(user=user))
+ )
+ # redirect to referer path
+ url = urljoin(base_host(request=request, is_app=True), path)
+ return HttpResponseRedirect(url)
+
+ except AuthenticationException as e:
+ params = e.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "sign-in?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
+
+
+class MagicSignUpEndpoint(View):
+
+ def post(self, request):
+
+ # set the referer as session to redirect after login
+ code = request.POST.get("code", "").strip()
+ email = request.POST.get("email", "").strip().lower()
+ next_path = request.POST.get("next_path")
+
+ if code == "" or email == "":
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED"
+ ],
+ error_message="MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
+ # Existing user
+ existing_user = User.objects.filter(email=email).first()
+ if existing_user:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"],
+ error_message="USER_ALREADY_EXIST",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
+
+ try:
+ provider = MagicCodeProvider(
+ request=request,
+ key=f"magic_{email}",
+ code=code,
+ callback=post_user_auth_workflow,
+ )
+ user = provider.authenticate()
+ # Login the user and record his device info
+ user_login(request=request, user=user, is_app=True)
+ # Get the redirection path
+ if next_path:
+ path = str(next_path)
+ else:
+ path = get_redirection_path(user=user)
+ # redirect to referer path
+ url = urljoin(base_host(request=request, is_app=True), path)
+ return HttpResponseRedirect(url)
+
+ except AuthenticationException as e:
+ params = e.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
diff --git a/apiserver/plane/authentication/views/app/password_management.py b/apiserver/plane/authentication/views/app/password_management.py
new file mode 100644
index 000000000..43054867e
--- /dev/null
+++ b/apiserver/plane/authentication/views/app/password_management.py
@@ -0,0 +1,197 @@
+# Python imports
+import os
+from urllib.parse import urlencode, urljoin
+
+# Third party imports
+from rest_framework import status
+from rest_framework.permissions import AllowAny
+from rest_framework.response import Response
+from rest_framework.views import APIView
+from zxcvbn import zxcvbn
+
+# Django imports
+from django.contrib.auth.tokens import PasswordResetTokenGenerator
+from django.core.exceptions import ValidationError
+from django.core.validators import validate_email
+from django.http import HttpResponseRedirect
+from django.utils.encoding import (
+ DjangoUnicodeDecodeError,
+ smart_bytes,
+ smart_str,
+)
+from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
+from django.views import View
+
+# Module imports
+from plane.bgtasks.forgot_password_task import forgot_password
+from plane.license.models import Instance
+from plane.db.models import User
+from plane.license.utils.instance_value import get_configuration_value
+from plane.authentication.utils.host import base_host
+from plane.authentication.adapter.error import (
+ AuthenticationException,
+ AUTHENTICATION_ERROR_CODES,
+)
+from plane.authentication.rate_limit import AuthenticationThrottle
+
+def generate_password_token(user):
+ uidb64 = urlsafe_base64_encode(smart_bytes(user.id))
+ token = PasswordResetTokenGenerator().make_token(user)
+
+ return uidb64, token
+
+
+class ForgotPasswordEndpoint(APIView):
+ permission_classes = [
+ AllowAny,
+ ]
+
+ throttle_classes = [
+ AuthenticationThrottle,
+ ]
+
+ def post(self, request):
+ email = request.data.get("email")
+
+ # Check instance configuration
+ instance = Instance.objects.first()
+ if instance is None or not instance.is_setup_done:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INSTANCE_NOT_CONFIGURED"
+ ],
+ error_message="INSTANCE_NOT_CONFIGURED",
+ )
+ return Response(
+ exc.get_error_dict(),
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ (EMAIL_HOST,) = get_configuration_value(
+ [
+ {
+ "key": "EMAIL_HOST",
+ "default": os.environ.get("EMAIL_HOST"),
+ },
+ ]
+ )
+
+ if not (EMAIL_HOST):
+ exc = AuthenticationException(
+ error_message="SMTP_NOT_CONFIGURED",
+ error_code=AUTHENTICATION_ERROR_CODES["SMTP_NOT_CONFIGURED"],
+ )
+ return Response(
+ exc.get_error_dict(),
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ try:
+ validate_email(email)
+ except ValidationError:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
+ error_message="INVALID_EMAIL",
+ )
+ return Response(
+ exc.get_error_dict(),
+ 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 = generate_password_token(user=user)
+ current_site = request.META.get("HTTP_ORIGIN")
+ # send the forgot password email
+ forgot_password.delay(
+ user.first_name, user.email, uidb64, token, current_site
+ )
+ return Response(
+ {"message": "Check your email to reset your password"},
+ status=status.HTTP_200_OK,
+ )
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"],
+ error_message="USER_DOES_NOT_EXIST",
+ )
+ return Response(
+ exc.get_error_dict(),
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+
+class ResetPasswordEndpoint(View):
+
+ def post(self, request, uidb64, token):
+ try:
+ # Decode the id from the uidb64
+ id = smart_str(urlsafe_base64_decode(uidb64))
+ user = User.objects.get(id=id)
+
+ # check if the token is valid for the user
+ if not PasswordResetTokenGenerator().check_token(user, token):
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INVALID_PASSWORD_TOKEN"
+ ],
+ error_message="INVALID_PASSWORD_TOKEN",
+ )
+ params = exc.get_error_dict()
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "accounts/reset-password?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
+
+ password = request.POST.get("password", False)
+
+ if not password:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
+ error_message="INVALID_PASSWORD",
+ )
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "accounts/reset-password?"
+ + urlencode(exc.get_error_dict()),
+ )
+ return HttpResponseRedirect(url)
+
+ # Check the password complexity
+ results = zxcvbn(password)
+ if results["score"] < 3:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
+ error_message="INVALID_PASSWORD",
+ )
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "accounts/reset-password?"
+ + urlencode(exc.get_error_dict()),
+ )
+ return HttpResponseRedirect(url)
+
+ # set_password also hashes the password that the user will get
+ user.set_password(password)
+ user.is_password_autoset = False
+ user.save()
+
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "sign-in?" + urlencode({"success": True}),
+ )
+ return HttpResponseRedirect(url)
+ except DjangoUnicodeDecodeError:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "EXPIRED_PASSWORD_TOKEN"
+ ],
+ error_message="EXPIRED_PASSWORD_TOKEN",
+ )
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "accounts/reset-password?" + urlencode(exc.get_error_dict()),
+ )
+ return HttpResponseRedirect(url)
diff --git a/apiserver/plane/authentication/views/app/signout.py b/apiserver/plane/authentication/views/app/signout.py
new file mode 100644
index 000000000..260a89a8d
--- /dev/null
+++ b/apiserver/plane/authentication/views/app/signout.py
@@ -0,0 +1,29 @@
+# Django imports
+from django.views import View
+from django.contrib.auth import logout
+from django.http import HttpResponseRedirect
+from django.utils import timezone
+
+# Module imports
+from plane.authentication.utils.host import user_ip, base_host
+from plane.db.models import User
+
+
+class SignOutAuthEndpoint(View):
+
+ def post(self, request):
+ # Get user
+ try:
+ user = User.objects.get(pk=request.user.id)
+ user.last_logout_ip = user_ip(request=request)
+ user.last_logout_time = timezone.now()
+ user.save()
+ # Log the user out
+ logout(request)
+ return HttpResponseRedirect(
+ base_host(request=request, is_app=True)
+ )
+ except Exception:
+ return HttpResponseRedirect(
+ base_host(request=request, is_app=True)
+ )
diff --git a/apiserver/plane/authentication/views/common.py b/apiserver/plane/authentication/views/common.py
new file mode 100644
index 000000000..3e95d6ed8
--- /dev/null
+++ b/apiserver/plane/authentication/views/common.py
@@ -0,0 +1,150 @@
+# Django imports
+from django.shortcuts import render
+
+# Third party imports
+from rest_framework import status
+from rest_framework.permissions import AllowAny
+from rest_framework.response import Response
+from rest_framework.views import APIView
+from zxcvbn import zxcvbn
+
+## Module imports
+from plane.app.serializers import (
+ UserSerializer,
+)
+from plane.authentication.utils.login import user_login
+from plane.db.models import User
+from plane.authentication.adapter.error import (
+ AuthenticationException,
+ AUTHENTICATION_ERROR_CODES,
+)
+from django.middleware.csrf import get_token
+from plane.utils.cache import invalidate_cache
+from plane.authentication.utils.host import base_host
+
+class CSRFTokenEndpoint(APIView):
+
+ permission_classes = [
+ AllowAny,
+ ]
+
+ def get(self, request):
+ # Generate a CSRF token
+ csrf_token = get_token(request)
+ # Return the CSRF token in a JSON response
+ return Response(
+ {"csrf_token": str(csrf_token)}, status=status.HTTP_200_OK
+ )
+
+
+def csrf_failure(request, reason=""):
+ """Custom CSRF failure view"""
+ return render(request, "csrf_failure.html", {"reason": reason, "root_url": base_host(request=request)})
+
+
+class ChangePasswordEndpoint(APIView):
+ def post(self, request):
+ user = User.objects.get(pk=request.user.id)
+
+ old_password = request.data.get("old_password", False)
+ new_password = request.data.get("new_password", False)
+
+ if not old_password or not new_password:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["MISSING_PASSWORD"],
+ error_message="MISSING_PASSWORD",
+ payload={"error": "Old or new password is missing"},
+ )
+ return Response(
+ exc.get_error_dict(),
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ if not user.check_password(old_password):
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INCORRECT_OLD_PASSWORD"
+ ],
+ error_message="INCORRECT_OLD_PASSWORD",
+ payload={"error": "Old password is not correct"},
+ )
+ return Response(
+ exc.get_error_dict(),
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # check the password score
+ results = zxcvbn(new_password)
+ if results["score"] < 3:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_NEW_PASSWORD"],
+ error_message="INVALID_NEW_PASSWORD",
+ )
+ return Response(
+ exc.get_error_dict(),
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # set_password also hashes the password that the user will get
+ user.set_password(new_password)
+ user.is_password_autoset = False
+ user.save()
+ user_login(user=user, request=request, is_app=True)
+ return Response(
+ {"message": "Password updated successfully"},
+ status=status.HTTP_200_OK,
+ )
+
+
+class SetUserPasswordEndpoint(APIView):
+
+ @invalidate_cache("/api/users/me/")
+ def post(self, request):
+ user = User.objects.get(pk=request.user.id)
+ password = request.data.get("password", False)
+
+ # If the user password is not autoset then return error
+ if not user.is_password_autoset:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_ALREADY_SET"],
+ error_message="PASSWORD_ALREADY_SET",
+ payload={
+ "error": "Your password is already set please change your password from profile"
+ },
+ )
+ return Response(
+ exc.get_error_dict(),
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # Check password validation
+ if not password:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
+ error_message="INVALID_PASSWORD",
+ )
+ return Response(
+ exc.get_error_dict(),
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ results = zxcvbn(password)
+ if results["score"] < 3:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
+ error_message="INVALID_PASSWORD",
+ )
+ return Response(
+ exc.get_error_dict(),
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # Set the user password
+ user.set_password(password)
+ user.is_password_autoset = False
+ user.save()
+ # Login the user as the session is invalidated
+ user_login(user=user, request=request, is_app=True)
+ # Return the user
+ serializer = UserSerializer(user)
+ return Response(serializer.data, status=status.HTTP_200_OK)
diff --git a/apiserver/plane/authentication/views/space/check.py b/apiserver/plane/authentication/views/space/check.py
new file mode 100644
index 000000000..a86a29c09
--- /dev/null
+++ b/apiserver/plane/authentication/views/space/check.py
@@ -0,0 +1,131 @@
+# Python imports
+import os
+
+# Django imports
+from django.core.validators import validate_email
+from django.core.exceptions import ValidationError
+
+# Third party imports
+from rest_framework import status
+from rest_framework.permissions import AllowAny
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
+## Module imports
+from plane.db.models import User
+from plane.license.models import Instance
+from plane.authentication.adapter.error import (
+ AUTHENTICATION_ERROR_CODES,
+ AuthenticationException,
+)
+from plane.authentication.rate_limit import AuthenticationThrottle
+from plane.license.utils.instance_value import get_configuration_value
+
+
+class EmailCheckSpaceEndpoint(APIView):
+
+ permission_classes = [
+ AllowAny,
+ ]
+
+ throttle_classes = [
+ AuthenticationThrottle,
+ ]
+
+ def post(self, request):
+ # Check instance configuration
+ instance = Instance.objects.first()
+ if instance is None or not instance.is_setup_done:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INSTANCE_NOT_CONFIGURED"
+ ],
+ error_message="INSTANCE_NOT_CONFIGURED",
+ )
+ return Response(
+ exc.get_error_dict(),
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ (EMAIL_HOST, ENABLE_MAGIC_LINK_LOGIN) = get_configuration_value(
+ [
+ {
+ "key": "EMAIL_HOST",
+ "default": os.environ.get("EMAIL_HOST", ""),
+ },
+ {
+ "key": "ENABLE_MAGIC_LINK_LOGIN",
+ "default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "1"),
+ },
+ ]
+ )
+
+ smtp_configured = bool(EMAIL_HOST)
+ is_magic_login_enabled = ENABLE_MAGIC_LINK_LOGIN == "1"
+
+ email = request.data.get("email", False)
+
+ # Return error if email is not present
+ if not email:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["EMAIL_REQUIRED"],
+ error_message="EMAIL_REQUIRED",
+ )
+ return Response(
+ exc.get_error_dict(),
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # Validate email
+ try:
+ validate_email(email)
+ except ValidationError:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
+ error_message="INVALID_EMAIL",
+ )
+ return Response(
+ exc.get_error_dict(),
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ # Check if a user already exists with the given email
+ existing_user = User.objects.filter(email=email).first()
+
+ # If existing user
+ if existing_user:
+ if not existing_user.is_active:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "USER_ACCOUNT_DEACTIVATED"
+ ],
+ error_message="USER_ACCOUNT_DEACTIVATED",
+ )
+ return Response(
+ exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST
+ )
+
+ return Response(
+ {
+ "existing": True,
+ "status": (
+ "MAGIC_CODE"
+ if existing_user.is_password_autoset
+ and smtp_configured
+ and is_magic_login_enabled
+ else "CREDENTIAL"
+ ),
+ },
+ status=status.HTTP_200_OK,
+ )
+ # Else return response
+ return Response(
+ {
+ "existing": False,
+ "status": (
+ "MAGIC_CODE"
+ if smtp_configured and is_magic_login_enabled
+ else "CREDENTIAL"
+ ),
+ },
+ status=status.HTTP_200_OK,
+ )
diff --git a/apiserver/plane/authentication/views/space/email.py b/apiserver/plane/authentication/views/space/email.py
new file mode 100644
index 000000000..7a5613a75
--- /dev/null
+++ b/apiserver/plane/authentication/views/space/email.py
@@ -0,0 +1,220 @@
+# Python imports
+from urllib.parse import urlencode
+
+# Django imports
+from django.core.exceptions import ValidationError
+from django.core.validators import validate_email
+from django.http import HttpResponseRedirect
+from django.views import View
+
+# Module imports
+from plane.authentication.provider.credentials.email import EmailProvider
+from plane.authentication.utils.login import user_login
+from plane.license.models import Instance
+from plane.authentication.utils.host import base_host
+from plane.db.models import User
+from plane.authentication.adapter.error import (
+ AUTHENTICATION_ERROR_CODES,
+ AuthenticationException,
+)
+
+
+class SignInAuthSpaceEndpoint(View):
+
+ def post(self, request):
+ next_path = request.POST.get("next_path")
+ # Check instance configuration
+ instance = Instance.objects.first()
+ if instance is None or not instance.is_setup_done:
+ # Redirection params
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INSTANCE_NOT_CONFIGURED"
+ ],
+ error_message="INSTANCE_NOT_CONFIGURED",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
+
+ # set the referer as session to redirect after login
+ email = request.POST.get("email", False)
+ password = request.POST.get("password", False)
+
+ ## Raise exception if any of the above are missing
+ if not email or not password:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "REQUIRED_EMAIL_PASSWORD_SIGN_IN"
+ ],
+ error_message="REQUIRED_EMAIL_PASSWORD_SIGN_IN",
+ payload={"email": str(email)},
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
+
+ # Validate email
+ email = email.strip().lower()
+ try:
+ validate_email(email)
+ except ValidationError:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL_SIGN_IN"],
+ error_message="INVALID_EMAIL_SIGN_IN",
+ payload={"email": str(email)},
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
+
+ # Existing User
+ existing_user = User.objects.filter(email=email).first()
+
+ if not existing_user:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"],
+ error_message="USER_DOES_NOT_EXIST",
+ payload={"email": str(email)},
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
+
+ if not existing_user.is_active:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "USER_ACCOUNT_DEACTIVATED"
+ ],
+ error_message="USER_ACCOUNT_DEACTIVATED",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
+
+ try:
+ provider = EmailProvider(
+ request=request, key=email, code=password, is_signup=False
+ )
+ user = provider.authenticate()
+ # Login the user and record his device info
+ user_login(request=request, user=user, is_space=True)
+ # redirect to next path
+ url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
+ return HttpResponseRedirect(url)
+ except AuthenticationException as e:
+ params = e.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
+
+
+class SignUpAuthSpaceEndpoint(View):
+
+ def post(self, request):
+ next_path = request.POST.get("next_path")
+ # Check instance configuration
+ instance = Instance.objects.first()
+ if instance is None or not instance.is_setup_done:
+ # Redirection params
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INSTANCE_NOT_CONFIGURED"
+ ],
+ error_message="INSTANCE_NOT_CONFIGURED",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
+
+ email = request.POST.get("email", False)
+ password = request.POST.get("password", False)
+ ## Raise exception if any of the above are missing
+ if not email or not password:
+ # Redirection params
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "REQUIRED_EMAIL_PASSWORD_SIGN_UP"
+ ],
+ error_message="REQUIRED_EMAIL_PASSWORD_SIGN_UP",
+ payload={"email": str(email)},
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
+ # Validate the email
+ email = email.strip().lower()
+ try:
+ validate_email(email)
+ except ValidationError:
+ # Redirection params
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL_SIGN_UP"],
+ error_message="INVALID_EMAIL_SIGN_UP",
+ payload={"email": str(email)},
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
+
+ # Existing User
+ existing_user = User.objects.filter(email=email).first()
+
+ if existing_user:
+ if not existing_user.is_active:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "USER_ACCOUNT_DEACTIVATED"
+ ],
+ error_message="USER_ACCOUNT_DEACTIVATED",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
+
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"],
+ error_message="USER_ALREADY_EXIST",
+ payload={"email": str(email)},
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
+
+ try:
+ provider = EmailProvider(
+ request=request, key=email, code=password, is_signup=True
+ )
+ user = provider.authenticate()
+ # Login the user and record his device info
+ user_login(request=request, user=user, is_space=True)
+ # redirect to referer path
+ url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
+ return HttpResponseRedirect(url)
+ except AuthenticationException as e:
+ params = e.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
diff --git a/apiserver/plane/authentication/views/space/github.py b/apiserver/plane/authentication/views/space/github.py
new file mode 100644
index 000000000..711f7eaa7
--- /dev/null
+++ b/apiserver/plane/authentication/views/space/github.py
@@ -0,0 +1,109 @@
+# Python imports
+import uuid
+from urllib.parse import urlencode
+
+# Django import
+from django.http import HttpResponseRedirect
+from django.views import View
+
+# Module imports
+from plane.authentication.provider.oauth.github import GitHubOAuthProvider
+from plane.authentication.utils.login import user_login
+from plane.license.models import Instance
+from plane.authentication.utils.host import base_host
+from plane.authentication.adapter.error import (
+ AUTHENTICATION_ERROR_CODES,
+ AuthenticationException,
+)
+
+
+class GitHubOauthInitiateSpaceEndpoint(View):
+
+ def get(self, request):
+ # Get host and next path
+ request.session["host"] = base_host(request=request, is_space=True)
+ next_path = request.GET.get("next_path")
+ if next_path:
+ request.session["next_path"] = str(next_path)
+
+ # Check instance configuration
+ instance = Instance.objects.first()
+ if instance is None or not instance.is_setup_done:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INSTANCE_NOT_CONFIGURED"
+ ],
+ error_message="INSTANCE_NOT_CONFIGURED",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
+
+ try:
+ state = uuid.uuid4().hex
+ provider = GitHubOAuthProvider(request=request, state=state)
+ request.session["state"] = state
+ auth_url = provider.get_auth_url()
+ return HttpResponseRedirect(auth_url)
+ except AuthenticationException as e:
+ params = e.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
+
+
+class GitHubCallbackSpaceEndpoint(View):
+
+ def get(self, request):
+ code = request.GET.get("code")
+ state = request.GET.get("state")
+ base_host = request.session.get("host")
+ next_path = request.session.get("next_path")
+
+ if state != request.session.get("state", ""):
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "GITHUB_OAUTH_PROVIDER_ERROR"
+ ],
+ error_message="GITHUB_OAUTH_PROVIDER_ERROR",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
+
+ if not code:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "GITHUB_OAUTH_PROVIDER_ERROR"
+ ],
+ error_message="GITHUB_OAUTH_PROVIDER_ERROR",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
+
+ try:
+ provider = GitHubOAuthProvider(
+ request=request,
+ code=code,
+ )
+ user = provider.authenticate()
+ # Login the user and record his device info
+ user_login(request=request, user=user, is_space=True)
+ # Process workspace and project invitations
+ # redirect to referer path
+ url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
+ return HttpResponseRedirect(url)
+ except AuthenticationException as e:
+ params = e.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
diff --git a/apiserver/plane/authentication/views/space/google.py b/apiserver/plane/authentication/views/space/google.py
new file mode 100644
index 000000000..38a2b910a
--- /dev/null
+++ b/apiserver/plane/authentication/views/space/google.py
@@ -0,0 +1,103 @@
+# Python imports
+import uuid
+from urllib.parse import urlencode
+
+# Django import
+from django.http import HttpResponseRedirect
+from django.views import View
+
+# Module imports
+from plane.authentication.provider.oauth.google import GoogleOAuthProvider
+from plane.authentication.utils.login import user_login
+from plane.license.models import Instance
+from plane.authentication.utils.host import base_host
+from plane.authentication.adapter.error import (
+ AuthenticationException,
+ AUTHENTICATION_ERROR_CODES,
+)
+
+
+class GoogleOauthInitiateSpaceEndpoint(View):
+ def get(self, request):
+ request.session["host"] = base_host(request=request, is_space=True)
+ next_path = request.GET.get("next_path")
+ if next_path:
+ request.session["next_path"] = str(next_path)
+
+ # Check instance configuration
+ instance = Instance.objects.first()
+ if instance is None or not instance.is_setup_done:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INSTANCE_NOT_CONFIGURED"
+ ],
+ error_message="INSTANCE_NOT_CONFIGURED",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
+
+ try:
+ state = uuid.uuid4().hex
+ provider = GoogleOAuthProvider(request=request, state=state)
+ request.session["state"] = state
+ auth_url = provider.get_auth_url()
+ return HttpResponseRedirect(auth_url)
+ except AuthenticationException as e:
+ params = e.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
+
+
+class GoogleCallbackSpaceEndpoint(View):
+ def get(self, request):
+ code = request.GET.get("code")
+ state = request.GET.get("state")
+ base_host = request.session.get("host")
+ next_path = request.session.get("next_path")
+
+ if state != request.session.get("state", ""):
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "GOOGLE_OAUTH_PROVIDER_ERROR"
+ ],
+ error_message="GOOGLE_OAUTH_PROVIDER_ERROR",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
+ if not code:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "GOOGLE_OAUTH_PROVIDER_ERROR"
+ ],
+ error_message="GOOGLE_OAUTH_PROVIDER_ERROR",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = next_path
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
+ try:
+ provider = GoogleOAuthProvider(
+ request=request,
+ code=code,
+ )
+ user = provider.authenticate()
+ # Login the user and record his device info
+ user_login(request=request, user=user, is_space=True)
+ # redirect to referer path
+ url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
+ return HttpResponseRedirect(url)
+ except AuthenticationException as e:
+ params = e.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
diff --git a/apiserver/plane/authentication/views/space/magic.py b/apiserver/plane/authentication/views/space/magic.py
new file mode 100644
index 000000000..0e859d44d
--- /dev/null
+++ b/apiserver/plane/authentication/views/space/magic.py
@@ -0,0 +1,192 @@
+# Python imports
+from urllib.parse import urlencode
+
+# Django imports
+from django.core.validators import validate_email
+from django.http import HttpResponseRedirect
+from django.views import View
+
+# Third party imports
+from rest_framework import status
+from rest_framework.permissions import AllowAny
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
+# Module imports
+from plane.authentication.provider.credentials.magic_code import (
+ MagicCodeProvider,
+)
+from plane.authentication.utils.login import user_login
+from plane.bgtasks.magic_link_code_task import magic_link
+from plane.license.models import Instance
+from plane.authentication.utils.host import base_host
+from plane.db.models import User, Profile
+from plane.authentication.adapter.error import (
+ AuthenticationException,
+ AUTHENTICATION_ERROR_CODES,
+)
+
+
+class MagicGenerateSpaceEndpoint(APIView):
+
+ permission_classes = [
+ AllowAny,
+ ]
+
+ def post(self, request):
+ # Check if instance is configured
+ instance = Instance.objects.first()
+ if instance is None or not instance.is_setup_done:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INSTANCE_NOT_CONFIGURED"
+ ],
+ error_message="INSTANCE_NOT_CONFIGURED",
+ )
+ return Response(
+ exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST
+ )
+
+ origin = base_host(request=request, is_space=True)
+ email = request.data.get("email", False)
+ try:
+ # Clean up the email
+ email = email.strip().lower()
+ validate_email(email)
+ adapter = MagicCodeProvider(request=request, key=email)
+ key, token = adapter.initiate()
+ # If the smtp is configured send through here
+ magic_link.delay(email, key, token, origin)
+ return Response({"key": str(key)}, status=status.HTTP_200_OK)
+ except AuthenticationException as e:
+ return Response(
+ e.get_error_dict(),
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+
+class MagicSignInSpaceEndpoint(View):
+
+ def post(self, request):
+
+ # set the referer as session to redirect after login
+ code = request.POST.get("code", "").strip()
+ email = request.POST.get("email", "").strip().lower()
+ next_path = request.POST.get("next_path")
+
+ if code == "" or email == "":
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED"
+ ],
+ error_message="MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
+
+ existing_user = User.objects.filter(email=email).first()
+
+ if not existing_user:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"],
+ error_message="USER_DOES_NOT_EXIST",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
+
+ # Active User
+ if not existing_user.is_active:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "USER_ACCOUNT_DEACTIVATED"
+ ],
+ error_message="USER_ACCOUNT_DEACTIVATED",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
+ try:
+ provider = MagicCodeProvider(
+ request=request, key=f"magic_{email}", code=code
+ )
+ user = provider.authenticate()
+ # Login the user and record his device info
+ user_login(request=request, user=user, is_space=True)
+ # redirect to referer path
+ profile = Profile.objects.get(user=user)
+ if user.is_password_autoset and profile.is_onboarded:
+ path = "accounts/set-password"
+ else:
+ # Get the redirection path
+ path = str(next_path) if next_path else ""
+ url = f"{base_host(request=request, is_space=True)}{path}"
+ return HttpResponseRedirect(url)
+
+ except AuthenticationException as e:
+ params = e.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
+
+
+class MagicSignUpSpaceEndpoint(View):
+
+ def post(self, request):
+
+ # set the referer as session to redirect after login
+ code = request.POST.get("code", "").strip()
+ email = request.POST.get("email", "").strip().lower()
+ next_path = request.POST.get("next_path")
+
+ if code == "" or email == "":
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED"
+ ],
+ error_message="MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
+ # Existing User
+ existing_user = User.objects.filter(email=email).first()
+ # Already existing
+ if existing_user:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"],
+ error_message="USER_ALREADY_EXIST",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
+
+ try:
+ provider = MagicCodeProvider(
+ request=request, key=f"magic_{email}", code=code
+ )
+ user = provider.authenticate()
+ # Login the user and record his device info
+ user_login(request=request, user=user, is_space=True)
+ # redirect to referer path
+ url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
+ return HttpResponseRedirect(url)
+
+ except AuthenticationException as e:
+ params = e.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
diff --git a/apiserver/plane/authentication/views/space/password_management.py b/apiserver/plane/authentication/views/space/password_management.py
new file mode 100644
index 000000000..3e0379b96
--- /dev/null
+++ b/apiserver/plane/authentication/views/space/password_management.py
@@ -0,0 +1,192 @@
+# Python imports
+import os
+from urllib.parse import urlencode
+
+# Third party imports
+from rest_framework import status
+from rest_framework.permissions import AllowAny
+from rest_framework.response import Response
+from rest_framework.views import APIView
+from zxcvbn import zxcvbn
+
+# Django imports
+from django.contrib.auth.tokens import PasswordResetTokenGenerator
+from django.core.exceptions import ValidationError
+from django.core.validators import validate_email
+from django.http import HttpResponseRedirect
+from django.utils.encoding import (
+ DjangoUnicodeDecodeError,
+ smart_bytes,
+ smart_str,
+)
+from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
+from django.views import View
+
+# Module imports
+from plane.bgtasks.forgot_password_task import forgot_password
+from plane.license.models import Instance
+from plane.db.models import User
+from plane.license.utils.instance_value import get_configuration_value
+from plane.authentication.utils.host import base_host
+from plane.authentication.adapter.error import (
+ AuthenticationException,
+ AUTHENTICATION_ERROR_CODES,
+)
+from plane.authentication.rate_limit import AuthenticationThrottle
+
+
+def generate_password_token(user):
+ uidb64 = urlsafe_base64_encode(smart_bytes(user.id))
+ token = PasswordResetTokenGenerator().make_token(user)
+
+ return uidb64, token
+
+
+class ForgotPasswordSpaceEndpoint(APIView):
+ permission_classes = [
+ AllowAny,
+ ]
+
+ throttle_classes = [
+ AuthenticationThrottle,
+ ]
+
+ def post(self, request):
+ email = request.data.get("email")
+
+ # Check instance configuration
+ instance = Instance.objects.first()
+ if instance is None or not instance.is_setup_done:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INSTANCE_NOT_CONFIGURED"
+ ],
+ error_message="INSTANCE_NOT_CONFIGURED",
+ )
+ return Response(
+ exc.get_error_dict(),
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ (EMAIL_HOST, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD) = (
+ 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"),
+ },
+ ]
+ )
+ )
+
+ if not (EMAIL_HOST):
+ exc = AuthenticationException(
+ error_message="SMTP_NOT_CONFIGURED",
+ error_code=AUTHENTICATION_ERROR_CODES["SMTP_NOT_CONFIGURED"],
+ )
+ return Response(
+ exc.get_error_dict(),
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ try:
+ validate_email(email)
+ except ValidationError:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
+ error_message="INVALID_EMAIL",
+ )
+ return Response(
+ exc.get_error_dict(),
+ 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 = generate_password_token(user=user)
+ current_site = request.META.get("HTTP_ORIGIN")
+ # send the forgot password email
+ forgot_password.delay(
+ user.first_name, user.email, uidb64, token, current_site
+ )
+ return Response(
+ {"message": "Check your email to reset your password"},
+ status=status.HTTP_200_OK,
+ )
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"],
+ error_message="USER_DOES_NOT_EXIST",
+ )
+ return Response(
+ exc.get_error_dict(),
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+
+class ResetPasswordSpaceEndpoint(View):
+
+ def post(self, request, uidb64, token):
+ try:
+ # Decode the id from the uidb64
+ id = smart_str(urlsafe_base64_decode(uidb64))
+ user = User.objects.get(id=id)
+
+ # check if the token is valid for the user
+ if not PasswordResetTokenGenerator().check_token(user, token):
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INVALID_PASSWORD_TOKEN"
+ ],
+ error_message="INVALID_PASSWORD_TOKEN",
+ )
+ params = exc.get_error_dict()
+ url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(params)}"
+ return HttpResponseRedirect(url)
+
+ password = request.POST.get("password", False)
+
+ if not password:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
+ error_message="INVALID_PASSWORD",
+ )
+ url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(exc.get_error_dict())}"
+ return HttpResponseRedirect(url)
+
+ # Check the password complexity
+ results = zxcvbn(password)
+ if results["score"] < 3:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
+ error_message="INVALID_PASSWORD",
+ )
+ url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(exc.get_error_dict())}"
+ return HttpResponseRedirect(url)
+
+ # set_password also hashes the password that the user will get
+ user.set_password(password)
+ user.is_password_autoset = False
+ user.save()
+
+ return HttpResponseRedirect(
+ base_host(request=request, is_space=True)
+ )
+ except DjangoUnicodeDecodeError:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "EXPIRED_PASSWORD_TOKEN"
+ ],
+ error_message="EXPIRED_PASSWORD_TOKEN",
+ )
+ url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(exc.get_error_dict())}"
+ return HttpResponseRedirect(url)
diff --git a/apiserver/plane/authentication/views/space/signout.py b/apiserver/plane/authentication/views/space/signout.py
new file mode 100644
index 000000000..d3f29bd8d
--- /dev/null
+++ b/apiserver/plane/authentication/views/space/signout.py
@@ -0,0 +1,29 @@
+# Django imports
+from django.views import View
+from django.contrib.auth import logout
+from django.http import HttpResponseRedirect
+from django.utils import timezone
+
+# Module imports
+from plane.authentication.utils.host import base_host, user_ip
+from plane.db.models import User
+
+
+class SignOutAuthSpaceEndpoint(View):
+
+ def post(self, request):
+ next_path = request.POST.get("next_path")
+
+ # Get user
+ try:
+ user = User.objects.get(pk=request.user.id)
+ user.last_logout_ip = user_ip(request=request)
+ user.last_logout_time = timezone.now()
+ user.save()
+ # Log the user out
+ logout(request)
+ url = f"{base_host(request=request, is_space=True)}{next_path}"
+ return HttpResponseRedirect(url)
+ except Exception:
+ url = f"{base_host(request=request, is_space=True)}{next_path}"
+ return HttpResponseRedirect(url)
diff --git a/apiserver/plane/bgtasks/api_logs_task.py b/apiserver/plane/bgtasks/api_logs_task.py
new file mode 100644
index 000000000..038b939d5
--- /dev/null
+++ b/apiserver/plane/bgtasks/api_logs_task.py
@@ -0,0 +1,15 @@
+from django.utils import timezone
+from datetime import timedelta
+from plane.db.models import APIActivityLog
+from celery import shared_task
+
+
+@shared_task
+def delete_api_logs():
+ # Get the logs older than 30 days to delete
+ logs_to_delete = APIActivityLog.objects.filter(
+ created_at__lte=timezone.now() - timedelta(days=30)
+ )
+
+ # Delete the logs
+ logs_to_delete._raw_delete(logs_to_delete.db)
diff --git a/apiserver/plane/bgtasks/email_notification_task.py b/apiserver/plane/bgtasks/email_notification_task.py
index 050f522c3..fa154828b 100644
--- a/apiserver/plane/bgtasks/email_notification_task.py
+++ b/apiserver/plane/bgtasks/email_notification_task.py
@@ -152,7 +152,7 @@ def process_mention(mention_component):
soup = BeautifulSoup(mention_component, "html.parser")
mentions = soup.find_all("mention-component")
for mention in mentions:
- user_id = mention["id"]
+ user_id = mention["entity_identifier"]
user = User.objects.get(pk=user_id)
user_name = user.display_name
highlighted_name = f"@{user_name}"
diff --git a/apiserver/plane/bgtasks/forgot_password_task.py b/apiserver/plane/bgtasks/forgot_password_task.py
index b30c9311f..f830eb1e2 100644
--- a/apiserver/plane/bgtasks/forgot_password_task.py
+++ b/apiserver/plane/bgtasks/forgot_password_task.py
@@ -5,6 +5,7 @@ import logging
from celery import shared_task
# Django imports
+# Third party imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
from django.utils.html import strip_tags
diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py
index 2d55d5579..007b3e48c 100644
--- a/apiserver/plane/bgtasks/issue_activites_task.py
+++ b/apiserver/plane/bgtasks/issue_activites_task.py
@@ -31,6 +31,7 @@ from plane.db.models import (
)
from plane.settings.redis import redis_instance
from plane.utils.exception_logger import log_exception
+from plane.bgtasks.webhook_task import webhook_activity
# Track Changes in name
@@ -1296,7 +1297,7 @@ def create_issue_vote_activity(
IssueActivity(
issue_id=issue_id,
actor_id=actor_id,
- verb="created",
+ verb="updated",
old_value=None,
new_value=requested_data.get("vote"),
field="vote",
@@ -1365,7 +1366,7 @@ def create_issue_relation_activity(
IssueActivity(
issue_id=issue_id,
actor_id=actor_id,
- verb="created",
+ verb="updated",
old_value="",
new_value=f"{issue.project.identifier}-{issue.sequence_id}",
field=requested_data.get("relation_type"),
@@ -1380,7 +1381,7 @@ def create_issue_relation_activity(
IssueActivity(
issue_id=related_issue,
actor_id=actor_id,
- verb="created",
+ verb="updated",
old_value="",
new_value=f"{issue.project.identifier}-{issue.sequence_id}",
field=(
@@ -1606,6 +1607,7 @@ def issue_activity(
subscriber=True,
notification=False,
origin=None,
+ inbox=None,
):
try:
issue_activities = []
@@ -1692,6 +1694,41 @@ def issue_activity(
except Exception as e:
log_exception(e)
+ for activity in issue_activities_created:
+ webhook_activity.delay(
+ event=(
+ "issue_comment"
+ if activity.field == "comment"
+ else "inbox_issue" if inbox else "issue"
+ ),
+ event_id=(
+ activity.issue_comment_id
+ if activity.field == "comment"
+ else inbox if inbox else activity.issue_id
+ ),
+ verb=activity.verb,
+ field=(
+ "description"
+ if activity.field == "comment"
+ else activity.field
+ ),
+ old_value=(
+ activity.old_value
+ if activity.old_value != ""
+ else None
+ ),
+ new_value=(
+ activity.new_value
+ if activity.new_value != ""
+ else None
+ ),
+ actor_id=activity.actor_id,
+ current_site=origin,
+ slug=activity.workspace.slug,
+ old_identifier=activity.old_identifier,
+ new_identifier=activity.new_identifier,
+ )
+
if notification:
notifications.delay(
type=type,
diff --git a/apiserver/plane/bgtasks/magic_link_code_task.py b/apiserver/plane/bgtasks/magic_link_code_task.py
index 4544e9889..7be0ae9f8 100644
--- a/apiserver/plane/bgtasks/magic_link_code_task.py
+++ b/apiserver/plane/bgtasks/magic_link_code_task.py
@@ -5,6 +5,7 @@ import logging
from celery import shared_task
# Django imports
+# Third party imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
from django.utils.html import strip_tags
diff --git a/apiserver/plane/bgtasks/notification_task.py b/apiserver/plane/bgtasks/notification_task.py
index 5725abc62..9dfd0c16d 100644
--- a/apiserver/plane/bgtasks/notification_task.py
+++ b/apiserver/plane/bgtasks/notification_task.py
@@ -128,7 +128,7 @@ def extract_mentions(issue_instance):
"mention-component", attrs={"target": "users"}
)
- mentions = [mention_tag["id"] for mention_tag in mention_tags]
+ mentions = [mention_tag["entity_identifier"] for mention_tag in mention_tags]
return list(set(mentions))
except Exception:
@@ -144,7 +144,7 @@ def extract_comment_mentions(comment_value):
"mention-component", attrs={"target": "users"}
)
for mention_tag in mentions_tags:
- mentions.append(mention_tag["id"])
+ mentions.append(mention_tag["entity_identifier"])
return list(set(mentions))
except Exception:
return []
@@ -663,9 +663,7 @@ def notifications(
"old_value": str(
last_activity.old_value
),
- "activity_time": issue_activity.get(
- "created_at"
- ),
+ "activity_time": str(last_activity.created_at),
},
},
)
diff --git a/apiserver/plane/bgtasks/webhook_task.py b/apiserver/plane/bgtasks/webhook_task.py
index 5ee0244c7..6696a569c 100644
--- a/apiserver/plane/bgtasks/webhook_task.py
+++ b/apiserver/plane/bgtasks/webhook_task.py
@@ -15,6 +15,7 @@ from django.core.mail import EmailMultiAlternatives, get_connection
from django.core.serializers.json import DjangoJSONEncoder
from django.template.loader import render_to_string
from django.utils.html import strip_tags
+from django.core.exceptions import ObjectDoesNotExist
# Module imports
from plane.api.serializers import (
@@ -25,6 +26,8 @@ from plane.api.serializers import (
ModuleIssueSerializer,
ModuleSerializer,
ProjectSerializer,
+ UserLiteSerializer,
+ InboxIssueSerializer,
)
from plane.db.models import (
Cycle,
@@ -37,6 +40,7 @@ from plane.db.models import (
User,
Webhook,
WebhookLog,
+ InboxIssue,
)
from plane.license.utils.instance_value import get_email_configuration
from plane.utils.exception_logger import log_exception
@@ -49,6 +53,8 @@ SERIALIZER_MAPPER = {
"cycle_issue": CycleIssueSerializer,
"module_issue": ModuleIssueSerializer,
"issue_comment": IssueCommentSerializer,
+ "user": UserLiteSerializer,
+ "inbox_issue": InboxIssueSerializer,
}
MODEL_MAPPER = {
@@ -59,6 +65,8 @@ MODEL_MAPPER = {
"cycle_issue": CycleIssue,
"module_issue": ModuleIssue,
"issue_comment": IssueComment,
+ "user": User,
+ "inbox_issue": InboxIssue,
}
@@ -179,64 +187,6 @@ def webhook_task(self, webhook, slug, event, event_data, action, current_site):
return
-@shared_task()
-def send_webhook(event, payload, kw, action, slug, bulk, current_site):
- try:
- webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True)
-
- if event == "project":
- webhooks = webhooks.filter(project=True)
-
- if event == "issue":
- webhooks = webhooks.filter(issue=True)
-
- if event == "module" or event == "module_issue":
- webhooks = webhooks.filter(module=True)
-
- if event == "cycle" or event == "cycle_issue":
- webhooks = webhooks.filter(cycle=True)
-
- if event == "issue_comment":
- webhooks = webhooks.filter(issue_comment=True)
-
- if webhooks:
- if action in ["POST", "PATCH"]:
- if bulk and event in ["cycle_issue", "module_issue"]:
- return
- else:
- event_data = [
- get_model_data(
- event=event,
- event_id=(
- payload.get("id")
- if isinstance(payload, dict)
- else kw.get("pk")
- ),
- many=False,
- )
- ]
-
- if action == "DELETE":
- event_data = [{"id": kw.get("pk")}]
-
- for webhook in webhooks:
- for data in event_data:
- webhook_task.delay(
- webhook=webhook.id,
- slug=slug,
- event=event,
- event_data=data,
- action=action,
- current_site=current_site,
- )
-
- except Exception as e:
- if settings.DEBUG:
- print(e)
- log_exception(e)
- return
-
-
@shared_task
def send_webhook_deactivation_email(
webhook_id, receiver_id, current_site, reason
@@ -294,3 +244,245 @@ def send_webhook_deactivation_email(
except Exception as e:
log_exception(e)
return
+
+
+@shared_task(
+ bind=True,
+ autoretry_for=(requests.RequestException,),
+ retry_backoff=600,
+ max_retries=5,
+ retry_jitter=True,
+)
+def webhook_send_task(
+ self,
+ webhook,
+ slug,
+ event,
+ event_data,
+ action,
+ current_site,
+ activity,
+):
+ try:
+ webhook = Webhook.objects.get(id=webhook, workspace__slug=slug)
+
+ headers = {
+ "Content-Type": "application/json",
+ "User-Agent": "Autopilot",
+ "X-Plane-Delivery": str(uuid.uuid4()),
+ "X-Plane-Event": event,
+ }
+
+ # # Your secret key
+ event_data = (
+ json.loads(json.dumps(event_data, cls=DjangoJSONEncoder))
+ if event_data is not None
+ else None
+ )
+
+ activity = (
+ json.loads(json.dumps(activity, cls=DjangoJSONEncoder))
+ if activity is not None
+ else None
+ )
+
+ action = {
+ "POST": "create",
+ "PATCH": "update",
+ "PUT": "update",
+ "DELETE": "delete",
+ }.get(action, action)
+
+ payload = {
+ "event": event,
+ "action": action,
+ "webhook_id": str(webhook.id),
+ "workspace_id": str(webhook.workspace_id),
+ "data": event_data,
+ "activity": activity,
+ }
+
+ # Use HMAC for generating signature
+ if webhook.secret_key:
+ hmac_signature = hmac.new(
+ webhook.secret_key.encode("utf-8"),
+ json.dumps(payload).encode("utf-8"),
+ hashlib.sha256,
+ )
+ signature = hmac_signature.hexdigest()
+ headers["X-Plane-Signature"] = signature
+
+ # Send the webhook event
+ response = requests.post(
+ webhook.url,
+ headers=headers,
+ json=payload,
+ timeout=30,
+ )
+
+ # Log the webhook request
+ WebhookLog.objects.create(
+ workspace_id=str(webhook.workspace_id),
+ webhook_id=str(webhook.id),
+ event_type=str(event),
+ request_method=str(action),
+ request_headers=str(headers),
+ request_body=str(payload),
+ response_status=str(response.status_code),
+ response_headers=str(response.headers),
+ response_body=str(response.text),
+ retry_count=str(self.request.retries),
+ )
+
+ except requests.RequestException as e:
+ # Log the failed webhook request
+ WebhookLog.objects.create(
+ workspace_id=str(webhook.workspace_id),
+ webhook_id=str(webhook.id),
+ event_type=str(event),
+ request_method=str(action),
+ request_headers=str(headers),
+ request_body=str(payload),
+ response_status=500,
+ response_headers="",
+ response_body=str(e),
+ retry_count=str(self.request.retries),
+ )
+ # Retry logic
+ if self.request.retries >= self.max_retries:
+ Webhook.objects.filter(pk=webhook.id).update(is_active=False)
+ if webhook:
+ # send email for the deactivation of the webhook
+ send_webhook_deactivation_email(
+ webhook_id=webhook.id,
+ receiver_id=webhook.created_by_id,
+ reason=str(e),
+ current_site=current_site,
+ )
+ return
+ raise requests.RequestException()
+
+ except Exception as e:
+ if settings.DEBUG:
+ print(e)
+ log_exception(e)
+ return
+
+
+@shared_task
+def webhook_activity(
+ event,
+ verb,
+ field,
+ old_value,
+ new_value,
+ actor_id,
+ slug,
+ current_site,
+ event_id,
+ old_identifier,
+ new_identifier,
+):
+ try:
+ webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True)
+
+ if event == "project":
+ webhooks = webhooks.filter(project=True)
+
+ if event == "issue":
+ webhooks = webhooks.filter(issue=True)
+
+ if event == "module" or event == "module_issue":
+ webhooks = webhooks.filter(module=True)
+
+ if event == "cycle" or event == "cycle_issue":
+ webhooks = webhooks.filter(cycle=True)
+
+ if event == "issue_comment":
+ webhooks = webhooks.filter(issue_comment=True)
+
+ for webhook in webhooks:
+ webhook_send_task.delay(
+ webhook=webhook.id,
+ slug=slug,
+ event=event,
+ event_data=get_model_data(
+ event=event,
+ event_id=event_id,
+ ),
+ action=verb,
+ current_site=current_site,
+ activity={
+ "field": field,
+ "new_value": new_value,
+ "old_value": old_value,
+ "actor": get_model_data(event="user", event_id=actor_id),
+ "old_identifier": old_identifier,
+ "new_identifier": new_identifier,
+ },
+ )
+ return
+ except Exception as e:
+ # Return if a does not exist error occurs
+ if isinstance(e, ObjectDoesNotExist):
+ return
+ if settings.DEBUG:
+ print(e)
+ log_exception(e)
+ return
+
+
+@shared_task
+def model_activity(
+ model_name,
+ model_id,
+ requested_data,
+ current_instance,
+ actor_id,
+ slug,
+ origin=None,
+):
+ """Function takes in two json and computes differences between keys of both the json"""
+ if current_instance is None:
+ webhook_activity.delay(
+ event=model_name,
+ verb="created",
+ field=None,
+ old_value=None,
+ new_value=None,
+ actor_id=actor_id,
+ slug=slug,
+ current_site=origin,
+ event_id=model_id,
+ old_identifier=None,
+ new_identifier=None,
+ )
+ return
+
+ # Load the current instance
+ current_instance = (
+ json.loads(current_instance) if current_instance is not None else None
+ )
+
+ # Loop through all keys in requested data and check the current value and requested value
+ for key in requested_data:
+ # Check if key is present in current instance or not
+ if key in current_instance:
+ current_value = current_instance.get(key, None)
+ requested_value = requested_data.get(key, None)
+ if current_value != requested_value:
+ webhook_activity.delay(
+ event=model_name,
+ verb="updated",
+ field=key,
+ old_value=current_value,
+ new_value=requested_value,
+ actor_id=actor_id,
+ slug=slug,
+ current_site=origin,
+ event_id=model_id,
+ old_identifier=None,
+ new_identifier=None,
+ )
+
+ return
diff --git a/apiserver/plane/celery.py b/apiserver/plane/celery.py
index 056dfb16b..d3e742f14 100644
--- a/apiserver/plane/celery.py
+++ b/apiserver/plane/celery.py
@@ -32,6 +32,10 @@ app.conf.beat_schedule = {
"task": "plane.bgtasks.email_notification_task.stack_email_notification",
"schedule": crontab(minute="*/5"),
},
+ "check-every-day-to-delete-api-logs": {
+ "task": "plane.bgtasks.api_logs_task.delete_api_logs",
+ "schedule": crontab(hour=0, minute=0),
+ },
}
# Load task modules from all registered Django app configs.
diff --git a/apiserver/plane/db/management/commands/reset_password.py b/apiserver/plane/db/management/commands/reset_password.py
index bca6c3560..9c137d320 100644
--- a/apiserver/plane/db/management/commands/reset_password.py
+++ b/apiserver/plane/db/management/commands/reset_password.py
@@ -2,7 +2,10 @@
import getpass
# Django imports
-from django.core.management import BaseCommand
+from django.core.management import BaseCommand, CommandError
+
+# Third party imports
+from zxcvbn import zxcvbn
# Module imports
from plane.db.models import User
@@ -46,6 +49,13 @@ class Command(BaseCommand):
self.stderr.write("Error: Blank passwords aren't allowed.")
return
+ results = zxcvbn(password)
+
+ if results["score"] < 3:
+ raise CommandError(
+ "Password is too common please set a complex password"
+ )
+
# Set user password
user.set_password(password)
user.is_password_autoset = False
diff --git a/apiserver/plane/db/management/commands/test_email.py b/apiserver/plane/db/management/commands/test_email.py
index 63b602518..facea7e9c 100644
--- a/apiserver/plane/db/management/commands/test_email.py
+++ b/apiserver/plane/db/management/commands/test_email.py
@@ -1,6 +1,9 @@
from django.core.mail import EmailMultiAlternatives, get_connection
from django.core.management import BaseCommand, CommandError
+from django.template.loader import render_to_string
+from django.utils.html import strip_tags
+# Module imports
from plane.license.utils.instance_value import get_email_configuration
@@ -37,10 +40,10 @@ class Command(BaseCommand):
timeout=30,
)
# Prepare email details
- subject = "Email Notification from Plane"
- message = (
- "This is a sample email notification sent from Plane application."
- )
+ subject = "Test email from Plane"
+
+ html_content = render_to_string("emails/test_email.html")
+ text_content = strip_tags(html_content)
self.stdout.write(self.style.SUCCESS("Trying to send test email..."))
@@ -48,11 +51,14 @@ class Command(BaseCommand):
try:
msg = EmailMultiAlternatives(
subject=subject,
- body=message,
+ body=text_content,
from_email=EMAIL_FROM,
- to=[receiver_email],
+ to=[
+ receiver_email,
+ ],
connection=connection,
)
+ msg.attach_alternative(html_content, "text/html")
msg.send()
self.stdout.write(self.style.SUCCESS("Email successfully sent"))
except Exception as e:
diff --git a/apiserver/plane/db/migrations/0065_auto_20240415_0937.py b/apiserver/plane/db/migrations/0065_auto_20240415_0937.py
new file mode 100644
index 000000000..4698c7120
--- /dev/null
+++ b/apiserver/plane/db/migrations/0065_auto_20240415_0937.py
@@ -0,0 +1,462 @@
+# Generated by Django 4.2.10 on 2024-04-04 08:47
+
+import uuid
+
+import django.db.models.deletion
+import django.utils.timezone
+from django.conf import settings
+from django.db import migrations, models
+
+import plane.db.models.user
+
+
+def migrate_user_profile(apps, schema_editor):
+ Profile = apps.get_model("db", "Profile")
+ User = apps.get_model("db", "User")
+
+ Profile.objects.bulk_create(
+ [
+ Profile(
+ user_id=user.get("id"),
+ theme=user.get("theme"),
+ is_tour_completed=user.get("is_tour_completed"),
+ use_case=user.get("use_case"),
+ is_onboarded=user.get("is_onboarded"),
+ last_workspace_id=user.get("last_workspace_id"),
+ billing_address_country=user.get("billing_address_country"),
+ billing_address=user.get("billing_address"),
+ has_billing_address=user.get("has_billing_address"),
+ )
+ for user in User.objects.values(
+ "id",
+ "theme",
+ "is_tour_completed",
+ "onboarding_step",
+ "use_case",
+ "role",
+ "is_onboarded",
+ "last_workspace_id",
+ "billing_address_country",
+ "billing_address",
+ "has_billing_address",
+ )
+ ],
+ batch_size=1000,
+ )
+
+
+def user_favorite_migration(apps, schema_editor):
+ # Import the models
+ CycleFavorite = apps.get_model("db", "CycleFavorite")
+ ModuleFavorite = apps.get_model("db", "ModuleFavorite")
+ ProjectFavorite = apps.get_model("db", "ProjectFavorite")
+ PageFavorite = apps.get_model("db", "PageFavorite")
+ IssueViewFavorite = apps.get_model("db", "IssueViewFavorite")
+ UserFavorite = apps.get_model("db", "UserFavorite")
+
+ # List of source models
+ source_models = [
+ CycleFavorite,
+ ModuleFavorite,
+ ProjectFavorite,
+ PageFavorite,
+ IssueViewFavorite,
+ ]
+
+ entity_mapper = {
+ "CycleFavorite": "cycle",
+ "ModuleFavorite": "module",
+ "ProjectFavorite": "project",
+ "PageFavorite": "page",
+ "IssueViewFavorite": "view",
+ }
+
+ for source_model in source_models:
+ entity_type = entity_mapper[source_model.__name__]
+ UserFavorite.objects.bulk_create(
+ [
+ UserFavorite(
+ user_id=obj.user_id,
+ entity_type=entity_type,
+ entity_identifier=str(getattr(obj, entity_type).id),
+ project_id=obj.project_id,
+ workspace_id=obj.workspace_id,
+ created_by_id=obj.created_by_id,
+ updated_by_id=obj.updated_by_id,
+ )
+ for obj in source_model.objects.all().iterator()
+ ],
+ batch_size=1000,
+ )
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("db", "0064_auto_20240409_1134"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="user",
+ name="avatar",
+ field=models.TextField(blank=True),
+ ),
+ migrations.CreateModel(
+ name="Session",
+ fields=[
+ (
+ "session_data",
+ models.TextField(verbose_name="session data"),
+ ),
+ (
+ "expire_date",
+ models.DateTimeField(
+ db_index=True, verbose_name="expire date"
+ ),
+ ),
+ (
+ "device_info",
+ models.JSONField(blank=True, default=None, null=True),
+ ),
+ (
+ "session_key",
+ models.CharField(
+ max_length=128, primary_key=True, serialize=False
+ ),
+ ),
+ ("user_id", models.CharField(max_length=50, null=True)),
+ ],
+ options={
+ "verbose_name": "session",
+ "verbose_name_plural": "sessions",
+ "db_table": "sessions",
+ "abstract": False,
+ },
+ ),
+ migrations.CreateModel(
+ name="Profile",
+ fields=[
+ (
+ "created_at",
+ models.DateTimeField(
+ auto_now_add=True, verbose_name="Created At"
+ ),
+ ),
+ (
+ "updated_at",
+ models.DateTimeField(
+ auto_now=True, verbose_name="Last Modified At"
+ ),
+ ),
+ (
+ "id",
+ models.UUIDField(
+ db_index=True,
+ default=uuid.uuid4,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ unique=True,
+ ),
+ ),
+ ("theme", models.JSONField(default=dict)),
+ ("is_tour_completed", models.BooleanField(default=False)),
+ (
+ "onboarding_step",
+ models.JSONField(
+ default=plane.db.models.user.get_default_onboarding
+ ),
+ ),
+ ("use_case", models.TextField(blank=True, null=True)),
+ (
+ "role",
+ models.CharField(blank=True, max_length=300, null=True),
+ ),
+ ("is_onboarded", models.BooleanField(default=False)),
+ ("last_workspace_id", models.UUIDField(null=True)),
+ (
+ "billing_address_country",
+ models.CharField(default="INDIA", max_length=255),
+ ),
+ ("billing_address", models.JSONField(null=True)),
+ ("has_billing_address", models.BooleanField(default=False)),
+ ("company_name", models.CharField(blank=True, max_length=255)),
+ (
+ "user",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="profile",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Profile",
+ "verbose_name_plural": "Profiles",
+ "db_table": "profiles",
+ "ordering": ("-created_at",),
+ },
+ ),
+ migrations.CreateModel(
+ name="Account",
+ fields=[
+ (
+ "created_at",
+ models.DateTimeField(
+ auto_now_add=True, verbose_name="Created At"
+ ),
+ ),
+ (
+ "updated_at",
+ models.DateTimeField(
+ auto_now=True, verbose_name="Last Modified At"
+ ),
+ ),
+ (
+ "id",
+ models.UUIDField(
+ db_index=True,
+ default=uuid.uuid4,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ unique=True,
+ ),
+ ),
+ ("provider_account_id", models.CharField(max_length=255)),
+ (
+ "provider",
+ models.CharField(
+ choices=[("google", "Google"), ("github", "Github")]
+ ),
+ ),
+ ("access_token", models.TextField()),
+ ("access_token_expired_at", models.DateTimeField(null=True)),
+ ("refresh_token", models.TextField(blank=True, null=True)),
+ ("refresh_token_expired_at", models.DateTimeField(null=True)),
+ (
+ "last_connected_at",
+ models.DateTimeField(default=django.utils.timezone.now),
+ ),
+ ("metadata", models.JSONField(default=dict)),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="accounts",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Account",
+ "verbose_name_plural": "Accounts",
+ "db_table": "accounts",
+ "ordering": ("-created_at",),
+ "unique_together": {("provider", "provider_account_id")},
+ },
+ ),
+ migrations.RunPython(migrate_user_profile),
+ migrations.RemoveField(
+ model_name="user",
+ name="billing_address",
+ ),
+ migrations.RemoveField(
+ model_name="user",
+ name="billing_address_country",
+ ),
+ migrations.RemoveField(
+ model_name="user",
+ name="has_billing_address",
+ ),
+ migrations.RemoveField(
+ model_name="user",
+ name="is_onboarded",
+ ),
+ migrations.RemoveField(
+ model_name="user",
+ name="is_tour_completed",
+ ),
+ migrations.RemoveField(
+ model_name="user",
+ name="last_workspace_id",
+ ),
+ migrations.RemoveField(
+ model_name="user",
+ name="my_issues_prop",
+ ),
+ migrations.RemoveField(
+ model_name="user",
+ name="onboarding_step",
+ ),
+ migrations.RemoveField(
+ model_name="user",
+ name="role",
+ ),
+ migrations.RemoveField(
+ model_name="user",
+ name="theme",
+ ),
+ migrations.RemoveField(
+ model_name="user",
+ name="use_case",
+ ),
+ migrations.AddField(
+ model_name="globalview",
+ name="logo_props",
+ field=models.JSONField(default=dict),
+ ),
+ # Pages
+ migrations.AddField(
+ model_name="page",
+ name="logo_props",
+ field=models.JSONField(default=dict),
+ ),
+ migrations.AddField(
+ model_name="page",
+ name="description_binary",
+ field=models.BinaryField(null=True),
+ ),
+ migrations.AlterField(
+ model_name="page",
+ name="name",
+ field=models.CharField(blank=True, max_length=255),
+ ),
+ # Estimates
+ migrations.AddField(
+ model_name="estimate",
+ name="type",
+ field=models.CharField(default="Categories", max_length=255),
+ ),
+ migrations.AlterField(
+ model_name="estimatepoint",
+ name="key",
+ field=models.IntegerField(
+ default=0,
+ validators=[
+ django.core.validators.MinValueValidator(0),
+ django.core.validators.MaxValueValidator(12),
+ ],
+ ),
+ ),
+ migrations.AlterField(
+ model_name="issue",
+ name="estimate_point",
+ field=models.IntegerField(
+ blank=True,
+ null=True,
+ validators=[
+ django.core.validators.MinValueValidator(0),
+ django.core.validators.MaxValueValidator(12),
+ ],
+ ),
+ ),
+ # workspace user properties
+ migrations.AlterModelTable(
+ name="workspaceuserproperties",
+ table="workspace_user_properties",
+ ),
+ # Favorites
+ migrations.CreateModel(
+ name="UserFavorite",
+ fields=[
+ (
+ "created_at",
+ models.DateTimeField(
+ auto_now_add=True, verbose_name="Created At"
+ ),
+ ),
+ (
+ "updated_at",
+ models.DateTimeField(
+ auto_now=True, verbose_name="Last Modified At"
+ ),
+ ),
+ (
+ "id",
+ models.UUIDField(
+ db_index=True,
+ default=uuid.uuid4,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ unique=True,
+ ),
+ ),
+ ("entity_type", models.CharField(max_length=100)),
+ ("entity_identifier", models.UUIDField(blank=True, null=True)),
+ (
+ "name",
+ models.CharField(blank=True, max_length=255, null=True),
+ ),
+ ("is_folder", models.BooleanField(default=False)),
+ ("sequence", models.IntegerField(default=65535)),
+ (
+ "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",
+ ),
+ ),
+ (
+ "parent",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="parent_folder",
+ to="db.userfavorite",
+ ),
+ ),
+ (
+ "project",
+ models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="project_%(class)s",
+ to="db.project",
+ ),
+ ),
+ (
+ "updated_by",
+ models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="%(class)s_updated_by",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Last Modified By",
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="favorites",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "workspace",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="workspace_%(class)s",
+ to="db.workspace",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "User Favorite",
+ "verbose_name_plural": "User Favorites",
+ "db_table": "user_favorites",
+ "ordering": ("-created_at",),
+ "unique_together": {
+ ("entity_type", "user", "entity_identifier")
+ },
+ },
+ ),
+ migrations.RunPython(user_favorite_migration),
+ ]
diff --git a/apiserver/plane/db/migrations/0066_account_id_token_cycle_logo_props_module_logo_props.py b/apiserver/plane/db/migrations/0066_account_id_token_cycle_logo_props_module_logo_props.py
new file mode 100644
index 000000000..2ad5b7481
--- /dev/null
+++ b/apiserver/plane/db/migrations/0066_account_id_token_cycle_logo_props_module_logo_props.py
@@ -0,0 +1,58 @@
+# Generated by Django 4.2.11 on 2024-05-22 15:04
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("db", "0065_auto_20240415_0937"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="account",
+ name="id_token",
+ field=models.TextField(blank=True),
+ ),
+ migrations.AddField(
+ model_name="cycle",
+ name="logo_props",
+ field=models.JSONField(default=dict),
+ ),
+ migrations.AddField(
+ model_name="module",
+ name="logo_props",
+ field=models.JSONField(default=dict),
+ ),
+ migrations.AddField(
+ model_name="issueview",
+ name="logo_props",
+ field=models.JSONField(default=dict),
+ ),
+ migrations.AddField(
+ model_name="inbox",
+ name="logo_props",
+ field=models.JSONField(default=dict),
+ ),
+ migrations.AddField(
+ model_name="dashboard",
+ name="logo_props",
+ field=models.JSONField(default=dict),
+ ),
+ migrations.AddField(
+ model_name="widget",
+ name="logo_props",
+ field=models.JSONField(default=dict),
+ ),
+ migrations.AddField(
+ model_name="issue",
+ name="description_binary",
+ field=models.BinaryField(null=True),
+ ),
+ migrations.AddField(
+ model_name="team",
+ name="logo_props",
+ field=models.JSONField(default=dict),
+ ),
+ ]
diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py
index daa793c37..b11ce7aa3 100644
--- a/apiserver/plane/db/models/__init__.py
+++ b/apiserver/plane/db/models/__init__.py
@@ -1,78 +1,80 @@
-from .base import BaseModel
-
-from .user import User
-
-from .workspace import (
- Workspace,
- WorkspaceMember,
- Team,
- WorkspaceMemberInvite,
- TeamMember,
- WorkspaceTheme,
- WorkspaceUserProperties,
- WorkspaceBaseModel,
-)
-
-from .project import (
- Project,
- ProjectMember,
- ProjectBaseModel,
- ProjectMemberInvite,
- ProjectIdentifier,
- ProjectFavorite,
- ProjectDeployBoard,
- ProjectPublicMember,
-)
-
-from .issue import (
- Issue,
- IssueActivity,
- IssueProperty,
- IssueComment,
- IssueLabel,
- IssueAssignee,
- Label,
- IssueBlocker,
- IssueRelation,
- IssueMention,
- IssueLink,
- IssueSequence,
- IssueAttachment,
- IssueSubscriber,
- IssueReaction,
- CommentReaction,
- IssueVote,
-)
-
+from .analytic import AnalyticView
+from .api import APIActivityLog, APIToken
from .asset import FileAsset
-
-from .social_connection import SocialLoginConnection
-
-from .state import State
-
-from .cycle import Cycle, CycleIssue, CycleFavorite, CycleUserProperties
-
-from .view import GlobalView, IssueView, IssueViewFavorite
-
-from .module import (
- Module,
- ModuleMember,
- ModuleIssue,
- ModuleLink,
- ModuleFavorite,
- ModuleUserProperties,
-)
-
-from .api import APIToken, APIActivityLog
-
+from .base import BaseModel
+from .cycle import Cycle, CycleFavorite, CycleIssue, CycleUserProperties
+from .dashboard import Dashboard, DashboardWidget, Widget
+from .estimate import Estimate, EstimatePoint
+from .exporter import ExporterHistory
+from .importer import Importer
+from .inbox import Inbox, InboxIssue
from .integration import (
- WorkspaceIntegration,
- Integration,
+ GithubCommentSync,
+ GithubIssueSync,
GithubRepository,
GithubRepositorySync,
- GithubIssueSync,
- GithubCommentSync,
+ Integration,
SlackProjectSync,
+ WorkspaceIntegration,
+)
+from .issue import (
+ CommentReaction,
+ Issue,
+ IssueActivity,
+ IssueAssignee,
+ IssueAttachment,
+ IssueBlocker,
+ IssueComment,
+ IssueLabel,
+ IssueLink,
+ IssueMention,
+ IssueProperty,
+ IssueReaction,
+ IssueRelation,
+ IssueSequence,
+ IssueSubscriber,
+ IssueVote,
+ Label,
+)
+from .module import (
+ Module,
+ ModuleFavorite,
+ ModuleIssue,
+ ModuleLink,
+ ModuleMember,
+ ModuleUserProperties,
+)
+from .notification import (
+ EmailNotificationLog,
+ Notification,
+ UserNotificationPreference,
+)
+from .page import Page, PageFavorite, PageLabel, PageLog
+from .project import (
+ Project,
+ ProjectBaseModel,
+ ProjectDeployBoard,
+ ProjectFavorite,
+ ProjectIdentifier,
+ ProjectMember,
+ ProjectMemberInvite,
+ ProjectPublicMember,
+)
+from .session import Session
+from .social_connection import SocialLoginConnection
+from .state import State
+from .user import Account, Profile, User
+from .view import GlobalView, IssueView, IssueViewFavorite
+from .webhook import Webhook, WebhookLog
+from .workspace import (
+ Team,
+ TeamMember,
+ Workspace,
+ WorkspaceBaseModel,
+ WorkspaceMember,
+ WorkspaceMemberInvite,
+ WorkspaceTheme,
+ WorkspaceUserProperties,
)
from .importer import Importer
@@ -96,3 +98,5 @@ from .exporter import ExporterHistory
from .webhook import Webhook, WebhookLog
from .dashboard import Dashboard, DashboardWidget, Widget
+
+from .favorite import UserFavorite
diff --git a/apiserver/plane/db/models/asset.py b/apiserver/plane/db/models/asset.py
index 713508613..7dd2f2c91 100644
--- a/apiserver/plane/db/models/asset.py
+++ b/apiserver/plane/db/models/asset.py
@@ -1,13 +1,14 @@
# Python imports
from uuid import uuid4
+from django.conf import settings
+from django.core.exceptions import ValidationError
+
# Django import
from django.db import models
-from django.core.exceptions import ValidationError
-from django.conf import settings
# Module import
-from . import BaseModel
+from .base import BaseModel
def get_upload_path(instance, filename):
diff --git a/apiserver/plane/db/models/cycle.py b/apiserver/plane/db/models/cycle.py
index 15a8251d7..5128ecbc5 100644
--- a/apiserver/plane/db/models/cycle.py
+++ b/apiserver/plane/db/models/cycle.py
@@ -1,9 +1,9 @@
# Django imports
-from django.db import models
from django.conf import settings
+from django.db import models
# Module imports
-from . import ProjectBaseModel
+from .project import ProjectBaseModel
def get_default_filters():
@@ -70,6 +70,7 @@ class Cycle(ProjectBaseModel):
external_id = models.CharField(max_length=255, blank=True, null=True)
progress_snapshot = models.JSONField(default=dict)
archived_at = models.DateTimeField(null=True)
+ logo_props = models.JSONField(default=dict)
class Meta:
verbose_name = "Cycle"
diff --git a/apiserver/plane/db/models/dashboard.py b/apiserver/plane/db/models/dashboard.py
index d07a70728..f21557a54 100644
--- a/apiserver/plane/db/models/dashboard.py
+++ b/apiserver/plane/db/models/dashboard.py
@@ -4,8 +4,8 @@ import uuid
from django.db import models
# Module imports
-from . import BaseModel
from ..mixins import TimeAuditModel
+from .base import BaseModel
class Dashboard(BaseModel):
@@ -31,6 +31,7 @@ class Dashboard(BaseModel):
verbose_name="Dashboard Type",
default="home",
)
+ logo_props = models.JSONField(default=dict)
def __str__(self):
"""Return name of the dashboard"""
@@ -53,6 +54,7 @@ class Widget(TimeAuditModel):
)
key = models.CharField(max_length=255)
filters = models.JSONField(default=dict)
+ logo_props = models.JSONField(default=dict)
def __str__(self):
"""Return name of the widget"""
diff --git a/apiserver/plane/db/models/estimate.py b/apiserver/plane/db/models/estimate.py
index bb57e788c..6ff1186c3 100644
--- a/apiserver/plane/db/models/estimate.py
+++ b/apiserver/plane/db/models/estimate.py
@@ -1,9 +1,9 @@
# Django imports
+from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
-from django.core.validators import MinValueValidator, MaxValueValidator
# Module imports
-from . import ProjectBaseModel
+from .project import ProjectBaseModel
class Estimate(ProjectBaseModel):
@@ -11,6 +11,7 @@ class Estimate(ProjectBaseModel):
description = models.TextField(
verbose_name="Estimate Description", blank=True
)
+ type = models.CharField(max_length=255, default="Categories")
def __str__(self):
"""Return name of the estimate"""
@@ -31,7 +32,7 @@ class EstimatePoint(ProjectBaseModel):
related_name="points",
)
key = models.IntegerField(
- default=0, validators=[MinValueValidator(0), MaxValueValidator(7)]
+ default=0, validators=[MinValueValidator(0), MaxValueValidator(12)]
)
description = models.TextField(blank=True)
value = models.CharField(max_length=20)
diff --git a/apiserver/plane/db/models/exporter.py b/apiserver/plane/db/models/exporter.py
index d427eb0f6..9790db68d 100644
--- a/apiserver/plane/db/models/exporter.py
+++ b/apiserver/plane/db/models/exporter.py
@@ -3,13 +3,14 @@ import uuid
# Python imports
from uuid import uuid4
-# Django imports
-from django.db import models
from django.conf import settings
from django.contrib.postgres.fields import ArrayField
+# Django imports
+from django.db import models
+
# Module imports
-from . import BaseModel
+from .base import BaseModel
def generate_token():
diff --git a/apiserver/plane/db/models/favorite.py b/apiserver/plane/db/models/favorite.py
new file mode 100644
index 000000000..2ea1014bc
--- /dev/null
+++ b/apiserver/plane/db/models/favorite.py
@@ -0,0 +1,52 @@
+from django.conf import settings
+
+# Django imports
+from django.db import models
+
+# Module imports
+from .workspace import WorkspaceBaseModel
+
+
+class UserFavorite(WorkspaceBaseModel):
+ """_summary_
+ UserFavorite (model): To store all the favorites of the user
+ """
+
+ user = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.CASCADE,
+ related_name="favorites",
+ )
+ entity_type = models.CharField(max_length=100)
+ entity_identifier = models.UUIDField(null=True, blank=True)
+ name = models.CharField(max_length=255, blank=True, null=True)
+ is_folder = models.BooleanField(default=False)
+ sequence = models.IntegerField(default=65535)
+ parent = models.ForeignKey(
+ "self",
+ on_delete=models.CASCADE,
+ null=True,
+ blank=True,
+ related_name="parent_folder",
+ )
+
+ class Meta:
+ unique_together = ["entity_type", "user", "entity_identifier"]
+ verbose_name = "User Favorite"
+ verbose_name_plural = "User Favorites"
+ db_table = "user_favorites"
+ ordering = ("-created_at",)
+
+ def save(self, *args, **kwargs):
+ if self._state.adding:
+ largest_sequence = UserFavorite.objects.filter(
+ workspace=self.project.workspace
+ ).aggregate(largest=models.Max("sequence"))["largest"]
+ if largest_sequence is not None:
+ self.sequence = largest_sequence + 10000
+
+ super(UserFavorite, self).save(*args, **kwargs)
+
+ def __str__(self):
+ """Return user and the entity type"""
+ return f"{self.user.email} <{self.entity_type}>"
diff --git a/apiserver/plane/db/models/importer.py b/apiserver/plane/db/models/importer.py
index 651927458..ebc7571d5 100644
--- a/apiserver/plane/db/models/importer.py
+++ b/apiserver/plane/db/models/importer.py
@@ -1,9 +1,9 @@
# Django imports
-from django.db import models
from django.conf import settings
+from django.db import models
# Module imports
-from . import ProjectBaseModel
+from .project import ProjectBaseModel
class Importer(ProjectBaseModel):
diff --git a/apiserver/plane/db/models/inbox.py b/apiserver/plane/db/models/inbox.py
index 809a11821..f45e90042 100644
--- a/apiserver/plane/db/models/inbox.py
+++ b/apiserver/plane/db/models/inbox.py
@@ -2,7 +2,7 @@
from django.db import models
# Module imports
-from plane.db.models import ProjectBaseModel
+from plane.db.models.project import ProjectBaseModel
class Inbox(ProjectBaseModel):
@@ -12,6 +12,7 @@ class Inbox(ProjectBaseModel):
)
is_default = models.BooleanField(default=False)
view_props = models.JSONField(default=dict)
+ logo_props = models.JSONField(default=dict)
def __str__(self):
"""Return name of the Inbox"""
diff --git a/apiserver/plane/db/models/integration/github.py b/apiserver/plane/db/models/integration/github.py
index 6a00dc690..9e4294175 100644
--- a/apiserver/plane/db/models/integration/github.py
+++ b/apiserver/plane/db/models/integration/github.py
@@ -4,7 +4,7 @@
from django.db import models
# Module imports
-from plane.db.models import ProjectBaseModel
+from plane.db.models.project import ProjectBaseModel
class GithubRepository(ProjectBaseModel):
diff --git a/apiserver/plane/db/models/integration/slack.py b/apiserver/plane/db/models/integration/slack.py
index 1f07179b7..94d5d7d83 100644
--- a/apiserver/plane/db/models/integration/slack.py
+++ b/apiserver/plane/db/models/integration/slack.py
@@ -4,7 +4,7 @@
from django.db import models
# Module imports
-from plane.db.models import ProjectBaseModel
+from plane.db.models.project import ProjectBaseModel
class SlackProjectSync(ProjectBaseModel):
diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py
index 01a43abca..527597ddc 100644
--- a/apiserver/plane/db/models/issue.py
+++ b/apiserver/plane/db/models/issue.py
@@ -2,19 +2,20 @@
from uuid import uuid4
# Django imports
-from django.contrib.postgres.fields import ArrayField
-from django.db import models
from django.conf import settings
+from django.contrib.postgres.fields import ArrayField
+from django.core.exceptions import ValidationError
+from django.core.validators import MaxValueValidator, MinValueValidator
+from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
-from django.core.validators import MinValueValidator, MaxValueValidator
-from django.core.exceptions import ValidationError
from django.utils import timezone
# Module imports
-from . import ProjectBaseModel
from plane.utils.html_processor import strip_tags
+from .project import ProjectBaseModel
+
def get_default_properties():
return {
@@ -119,7 +120,7 @@ class Issue(ProjectBaseModel):
related_name="state_issue",
)
estimate_point = models.IntegerField(
- validators=[MinValueValidator(0), MaxValueValidator(7)],
+ validators=[MinValueValidator(0), MaxValueValidator(12)],
null=True,
blank=True,
)
@@ -127,6 +128,7 @@ class Issue(ProjectBaseModel):
description = models.JSONField(blank=True, default=dict)
description_html = models.TextField(blank=True, default="
")
description_stripped = models.TextField(blank=True, null=True)
+ description_binary = models.BinaryField(null=True)
priority = models.CharField(
max_length=30,
choices=PRIORITY_CHOICES,
diff --git a/apiserver/plane/db/models/module.py b/apiserver/plane/db/models/module.py
index b201e4d7f..a6b55f246 100644
--- a/apiserver/plane/db/models/module.py
+++ b/apiserver/plane/db/models/module.py
@@ -1,9 +1,9 @@
# Django imports
-from django.db import models
from django.conf import settings
+from django.db import models
# Module imports
-from . import ProjectBaseModel
+from .project import ProjectBaseModel
def get_default_filters():
@@ -93,6 +93,7 @@ class Module(ProjectBaseModel):
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
archived_at = models.DateTimeField(null=True)
+ logo_props = models.JSONField(default=dict)
class Meta:
unique_together = ["name", "project"]
diff --git a/apiserver/plane/db/models/notification.py b/apiserver/plane/db/models/notification.py
index 9138ece9f..33241e05d 100644
--- a/apiserver/plane/db/models/notification.py
+++ b/apiserver/plane/db/models/notification.py
@@ -1,9 +1,10 @@
# Django imports
-from django.db import models
from django.conf import settings
+from django.db import models
# Module imports
-from . import BaseModel
+from .base import BaseModel
+
class Notification(BaseModel):
diff --git a/apiserver/plane/db/models/page.py b/apiserver/plane/db/models/page.py
index da7e050bb..3602bce1f 100644
--- a/apiserver/plane/db/models/page.py
+++ b/apiserver/plane/db/models/page.py
@@ -1,20 +1,22 @@
import uuid
+from django.conf import settings
+
# Django imports
from django.db import models
-from django.conf import settings
# Module imports
-from . import ProjectBaseModel
from plane.utils.html_processor import strip_tags
+from .project import ProjectBaseModel
+
def get_view_props():
return {"full_width": False}
class Page(ProjectBaseModel):
- name = models.CharField(max_length=255)
+ name = models.CharField(max_length=255, blank=True)
description = models.JSONField(default=dict, blank=True)
description_html = models.TextField(blank=True, default="
")
description_stripped = models.TextField(blank=True, null=True)
@@ -40,6 +42,8 @@ class Page(ProjectBaseModel):
archived_at = models.DateField(null=True)
is_locked = models.BooleanField(default=False)
view_props = models.JSONField(default=get_view_props)
+ logo_props = models.JSONField(default=dict)
+ description_binary = models.BinaryField(null=True)
class Meta:
verbose_name = "Page"
@@ -121,7 +125,7 @@ class PageBlock(ProjectBaseModel):
if self.completed_at and self.issue:
try:
- from plane.db.models import State, Issue
+ from plane.db.models import Issue, State
completed_state = State.objects.filter(
group="completed", project=self.project
diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py
index db5ebf33b..49fca1323 100644
--- a/apiserver/plane/db/models/project.py
+++ b/apiserver/plane/db/models/project.py
@@ -2,15 +2,15 @@
from uuid import uuid4
# Django imports
-from django.db import models
from django.conf import settings
-from django.core.validators import MinValueValidator, MaxValueValidator
+from django.core.validators import MaxValueValidator, MinValueValidator
+from django.db import models
# Modeule imports
from plane.db.mixins import AuditModel
# Module imports
-from . import BaseModel
+from .base import BaseModel
ROLE_CHOICES = (
(20, "Admin"),
diff --git a/apiserver/plane/db/models/session.py b/apiserver/plane/db/models/session.py
new file mode 100644
index 000000000..95e8e0b7d
--- /dev/null
+++ b/apiserver/plane/db/models/session.py
@@ -0,0 +1,65 @@
+# Python imports
+import string
+
+# Django imports
+from django.contrib.sessions.backends.db import SessionStore as DBSessionStore
+from django.contrib.sessions.base_session import AbstractBaseSession
+from django.db import models
+from django.utils.crypto import get_random_string
+
+VALID_KEY_CHARS = string.ascii_lowercase + string.digits
+
+
+class Session(AbstractBaseSession):
+ device_info = models.JSONField(
+ null=True,
+ blank=True,
+ default=None,
+ )
+ session_key = models.CharField(
+ max_length=128,
+ primary_key=True,
+ )
+ user_id = models.CharField(
+ null=True,
+ max_length=50,
+ )
+
+ @classmethod
+ def get_session_store_class(cls):
+ return SessionStore
+
+ class Meta(AbstractBaseSession.Meta):
+ db_table = "sessions"
+
+
+class SessionStore(DBSessionStore):
+
+ @classmethod
+ def get_model_class(cls):
+ return Session
+
+ def _get_new_session_key(self):
+ """
+ Return a new session key that is not present in the current backend.
+ Override this method to use a custom session key generation mechanism.
+ """
+ while True:
+ session_key = get_random_string(128, VALID_KEY_CHARS)
+ if not self.exists(session_key):
+ return session_key
+
+ def create_model_instance(self, data):
+ obj = super().create_model_instance(data)
+ try:
+ user_id = data.get("_auth_user_id")
+ except (ValueError, TypeError):
+ user_id = None
+ obj.user_id = user_id
+
+ # Save the device info
+ device_info = data.get("device_info")
+ obj.device_info = (
+ device_info if isinstance(device_info, dict) else None
+ )
+ return obj
diff --git a/apiserver/plane/db/models/social_connection.py b/apiserver/plane/db/models/social_connection.py
index 73028e419..96fbbb967 100644
--- a/apiserver/plane/db/models/social_connection.py
+++ b/apiserver/plane/db/models/social_connection.py
@@ -1,10 +1,10 @@
# Django imports
-from django.db import models
from django.conf import settings
+from django.db import models
from django.utils import timezone
# Module import
-from . import BaseModel
+from .base import BaseModel
class SocialLoginConnection(BaseModel):
diff --git a/apiserver/plane/db/models/state.py b/apiserver/plane/db/models/state.py
index 28e3b25a1..36e053e22 100644
--- a/apiserver/plane/db/models/state.py
+++ b/apiserver/plane/db/models/state.py
@@ -3,7 +3,7 @@ from django.db import models
from django.template.defaultfilters import slugify
# Module imports
-from . import ProjectBaseModel
+from .project import ProjectBaseModel
class State(ProjectBaseModel):
diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py
index 5f932d2ea..c083b631c 100644
--- a/apiserver/plane/db/models/user.py
+++ b/apiserver/plane/db/models/user.py
@@ -16,6 +16,9 @@ from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
+# Module imports
+from ..mixins import TimeAuditModel
+
def get_default_onboarding():
return {
@@ -35,15 +38,17 @@ class User(AbstractBaseUser, PermissionsMixin):
primary_key=True,
)
username = models.CharField(max_length=128, unique=True)
-
# user fields
mobile_number = models.CharField(max_length=255, blank=True, null=True)
email = models.CharField(
max_length=255, null=True, blank=True, unique=True
)
+
+ # identity
+ display_name = models.CharField(max_length=255, default="")
first_name = models.CharField(max_length=255, blank=True)
last_name = models.CharField(max_length=255, blank=True)
- avatar = models.CharField(max_length=255, blank=True)
+ avatar = models.TextField(blank=True)
cover_image = models.URLField(blank=True, null=True, max_length=800)
# tracking metrics
@@ -67,19 +72,10 @@ class User(AbstractBaseUser, PermissionsMixin):
is_staff = models.BooleanField(default=False)
is_email_verified = models.BooleanField(default=False)
is_password_autoset = models.BooleanField(default=False)
- is_onboarded = models.BooleanField(default=False)
+ # random token generated
token = models.CharField(max_length=64, blank=True)
- billing_address_country = models.CharField(max_length=255, default="INDIA")
- billing_address = models.JSONField(null=True)
- has_billing_address = models.BooleanField(default=False)
-
- USER_TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones))
- user_timezone = models.CharField(
- max_length=255, default="UTC", choices=USER_TIMEZONE_CHOICES
- )
-
last_active = models.DateTimeField(default=timezone.now, null=True)
last_login_time = models.DateTimeField(null=True)
last_logout_time = models.DateTimeField(null=True)
@@ -91,18 +87,17 @@ class User(AbstractBaseUser, PermissionsMixin):
)
last_login_uagent = models.TextField(blank=True)
token_updated_at = models.DateTimeField(null=True)
- last_workspace_id = models.UUIDField(null=True)
- my_issues_prop = models.JSONField(null=True)
- role = models.CharField(max_length=300, null=True, blank=True)
+ # my_issues_prop = models.JSONField(null=True)
+
is_bot = models.BooleanField(default=False)
- theme = models.JSONField(default=dict)
- display_name = models.CharField(max_length=255, default="")
- is_tour_completed = models.BooleanField(default=False)
- onboarding_step = models.JSONField(default=get_default_onboarding)
- use_case = models.TextField(blank=True, null=True)
+
+ # timezone
+ USER_TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones))
+ user_timezone = models.CharField(
+ max_length=255, default="UTC", choices=USER_TIMEZONE_CHOICES
+ )
USERNAME_FIELD = "email"
-
REQUIRED_FIELDS = ["username"]
objects = UserManager()
@@ -139,6 +134,72 @@ class User(AbstractBaseUser, PermissionsMixin):
super(User, self).save(*args, **kwargs)
+class Profile(TimeAuditModel):
+ id = models.UUIDField(
+ default=uuid.uuid4,
+ unique=True,
+ editable=False,
+ db_index=True,
+ primary_key=True,
+ )
+ # User
+ user = models.OneToOneField(
+ "db.User", on_delete=models.CASCADE, related_name="profile"
+ )
+ # General
+ theme = models.JSONField(default=dict)
+ # Onboarding
+ is_tour_completed = models.BooleanField(default=False)
+ onboarding_step = models.JSONField(default=get_default_onboarding)
+ use_case = models.TextField(blank=True, null=True)
+ role = models.CharField(max_length=300, null=True, blank=True) # job role
+ is_onboarded = models.BooleanField(default=False)
+ # Last visited workspace
+ last_workspace_id = models.UUIDField(null=True)
+ # address data
+ billing_address_country = models.CharField(max_length=255, default="INDIA")
+ billing_address = models.JSONField(null=True)
+ has_billing_address = models.BooleanField(default=False)
+ company_name = models.CharField(max_length=255, blank=True)
+
+ class Meta:
+ verbose_name = "Profile"
+ verbose_name_plural = "Profiles"
+ db_table = "profiles"
+ ordering = ("-created_at",)
+
+
+class Account(TimeAuditModel):
+ id = models.UUIDField(
+ default=uuid.uuid4,
+ unique=True,
+ editable=False,
+ db_index=True,
+ primary_key=True,
+ )
+ user = models.ForeignKey(
+ "db.User", on_delete=models.CASCADE, related_name="accounts"
+ )
+ provider_account_id = models.CharField(max_length=255)
+ provider = models.CharField(
+ choices=(("google", "Google"), ("github", "Github")),
+ )
+ access_token = models.TextField()
+ access_token_expired_at = models.DateTimeField(null=True)
+ refresh_token = models.TextField(null=True, blank=True)
+ refresh_token_expired_at = models.DateTimeField(null=True)
+ last_connected_at = models.DateTimeField(default=timezone.now)
+ id_token = models.TextField(blank=True)
+ metadata = models.JSONField(default=dict)
+
+ class Meta:
+ unique_together = ["provider", "provider_account_id"]
+ verbose_name = "Account"
+ verbose_name_plural = "Accounts"
+ db_table = "accounts"
+ ordering = ("-created_at",)
+
+
@receiver(post_save, sender=User)
def create_user_notification(sender, instance, created, **kwargs):
# create preferences
diff --git a/apiserver/plane/db/models/view.py b/apiserver/plane/db/models/view.py
index 13500b5a4..8916bd406 100644
--- a/apiserver/plane/db/models/view.py
+++ b/apiserver/plane/db/models/view.py
@@ -1,9 +1,11 @@
# Django imports
-from django.db import models
from django.conf import settings
+from django.db import models
# Module import
-from . import ProjectBaseModel, BaseModel, WorkspaceBaseModel
+from .base import BaseModel
+from .project import ProjectBaseModel
+from .workspace import WorkspaceBaseModel
def get_default_filters():
@@ -50,6 +52,7 @@ def get_default_display_properties():
}
+# DEPRECATED TODO: - Remove in next release
class GlobalView(BaseModel):
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="global_views"
@@ -62,6 +65,7 @@ class GlobalView(BaseModel):
)
query_data = models.JSONField(default=dict)
sort_order = models.FloatField(default=65535)
+ logo_props = models.JSONField(default=dict)
class Meta:
verbose_name = "Global View"
@@ -97,6 +101,7 @@ class IssueView(WorkspaceBaseModel):
default=1, choices=((0, "Private"), (1, "Public"))
)
sort_order = models.FloatField(default=65535)
+ logo_props = models.JSONField(default=dict)
class Meta:
verbose_name = "Issue View"
diff --git a/apiserver/plane/db/models/workspace.py b/apiserver/plane/db/models/workspace.py
index 7e5d6d90b..f9cd681ec 100644
--- a/apiserver/plane/db/models/workspace.py
+++ b/apiserver/plane/db/models/workspace.py
@@ -1,11 +1,10 @@
# Django imports
-from django.db import models
from django.conf import settings
from django.core.exceptions import ValidationError
+from django.db import models
# Module imports
-from . import BaseModel
-
+from .base import BaseModel
ROLE_CHOICES = (
(20, "Owner"),
@@ -245,6 +244,7 @@ class Team(BaseModel):
workspace = models.ForeignKey(
Workspace, on_delete=models.CASCADE, related_name="workspace_team"
)
+ logo_props = models.JSONField(default=dict)
def __str__(self):
"""Return name of the team"""
@@ -326,7 +326,7 @@ class WorkspaceUserProperties(BaseModel):
unique_together = ["workspace", "user"]
verbose_name = "Workspace User Property"
verbose_name_plural = "Workspace User Property"
- db_table = "Workspace_user_properties"
+ db_table = "workspace_user_properties"
ordering = ("-created_at",)
def __str__(self):
diff --git a/apiserver/plane/license/api/serializers/__init__.py b/apiserver/plane/license/api/serializers/__init__.py
index e6beda0e9..7b9cb676f 100644
--- a/apiserver/plane/license/api/serializers/__init__.py
+++ b/apiserver/plane/license/api/serializers/__init__.py
@@ -1,5 +1,6 @@
from .instance import (
InstanceSerializer,
- InstanceAdminSerializer,
- InstanceConfigurationSerializer,
)
+
+from .configuration import InstanceConfigurationSerializer
+from .admin import InstanceAdminSerializer, InstanceAdminMeSerializer
diff --git a/apiserver/plane/license/api/serializers/admin.py b/apiserver/plane/license/api/serializers/admin.py
new file mode 100644
index 000000000..848e94ef7
--- /dev/null
+++ b/apiserver/plane/license/api/serializers/admin.py
@@ -0,0 +1,41 @@
+# Module imports
+from .base import BaseSerializer
+from plane.db.models import User
+from plane.app.serializers import UserAdminLiteSerializer
+from plane.license.models import InstanceAdmin
+
+
+class InstanceAdminMeSerializer(BaseSerializer):
+ class Meta:
+ model = User
+ fields = [
+ "id",
+ "avatar",
+ "cover_image",
+ "date_joined",
+ "display_name",
+ "email",
+ "first_name",
+ "last_name",
+ "is_active",
+ "is_bot",
+ "is_email_verified",
+ "user_timezone",
+ "username",
+ "is_password_autoset",
+ "is_email_verified",
+ ]
+ read_only_fields = fields
+
+
+class InstanceAdminSerializer(BaseSerializer):
+ user_detail = UserAdminLiteSerializer(source="user", read_only=True)
+
+ class Meta:
+ model = InstanceAdmin
+ fields = "__all__"
+ read_only_fields = [
+ "id",
+ "instance",
+ "user",
+ ]
diff --git a/apiserver/plane/license/api/serializers/base.py b/apiserver/plane/license/api/serializers/base.py
new file mode 100644
index 000000000..0c6bba468
--- /dev/null
+++ b/apiserver/plane/license/api/serializers/base.py
@@ -0,0 +1,5 @@
+from rest_framework import serializers
+
+
+class BaseSerializer(serializers.ModelSerializer):
+ id = serializers.PrimaryKeyRelatedField(read_only=True)
diff --git a/apiserver/plane/license/api/serializers/configuration.py b/apiserver/plane/license/api/serializers/configuration.py
new file mode 100644
index 000000000..1766f2113
--- /dev/null
+++ b/apiserver/plane/license/api/serializers/configuration.py
@@ -0,0 +1,17 @@
+from .base import BaseSerializer
+from plane.license.models import InstanceConfiguration
+from plane.license.utils.encryption import decrypt_data
+
+
+class InstanceConfigurationSerializer(BaseSerializer):
+ class Meta:
+ model = InstanceConfiguration
+ fields = "__all__"
+
+ def to_representation(self, instance):
+ data = super().to_representation(instance)
+ # Decrypt secrets value
+ if instance.is_encrypted and instance.value is not None:
+ data["value"] = decrypt_data(instance.value)
+
+ return data
diff --git a/apiserver/plane/license/api/serializers/instance.py b/apiserver/plane/license/api/serializers/instance.py
index 8a99acbae..92e82d012 100644
--- a/apiserver/plane/license/api/serializers/instance.py
+++ b/apiserver/plane/license/api/serializers/instance.py
@@ -1,8 +1,7 @@
# Module imports
-from plane.license.models import Instance, InstanceAdmin, InstanceConfiguration
+from plane.license.models import Instance
from plane.app.serializers import BaseSerializer
from plane.app.serializers import UserAdminLiteSerializer
-from plane.license.utils.encryption import decrypt_data
class InstanceSerializer(BaseSerializer):
@@ -12,41 +11,15 @@ class InstanceSerializer(BaseSerializer):
class Meta:
model = Instance
- fields = "__all__"
- read_only_fields = [
- "id",
- "instance_id",
+ exclude = [
"license_key",
"api_key",
"version",
+ ]
+ read_only_fields = [
+ "id",
+ "instance_id",
"email",
"last_checked_at",
"is_setup_done",
]
-
-
-class InstanceAdminSerializer(BaseSerializer):
- user_detail = UserAdminLiteSerializer(source="user", read_only=True)
-
- class Meta:
- model = InstanceAdmin
- fields = "__all__"
- read_only_fields = [
- "id",
- "instance",
- "user",
- ]
-
-
-class InstanceConfigurationSerializer(BaseSerializer):
- class Meta:
- model = InstanceConfiguration
- fields = "__all__"
-
- def to_representation(self, instance):
- data = super().to_representation(instance)
- # Decrypt secrets value
- if instance.is_encrypted and instance.value is not None:
- data["value"] = decrypt_data(instance.value)
-
- return data
diff --git a/apiserver/plane/license/api/views/__init__.py b/apiserver/plane/license/api/views/__init__.py
index 3a66c94c5..b10702b8a 100644
--- a/apiserver/plane/license/api/views/__init__.py
+++ b/apiserver/plane/license/api/views/__init__.py
@@ -1,7 +1,20 @@
from .instance import (
InstanceEndpoint,
- InstanceAdminEndpoint,
- InstanceConfigurationEndpoint,
- InstanceAdminSignInEndpoint,
SignUpScreenVisitedEndpoint,
)
+
+
+from .configuration import (
+ EmailCredentialCheckEndpoint,
+ InstanceConfigurationEndpoint,
+)
+
+
+from .admin import (
+ InstanceAdminEndpoint,
+ InstanceAdminSignInEndpoint,
+ InstanceAdminSignUpEndpoint,
+ InstanceAdminUserMeEndpoint,
+ InstanceAdminSignOutEndpoint,
+ InstanceAdminUserSessionEndpoint,
+)
diff --git a/apiserver/plane/license/api/views/admin.py b/apiserver/plane/license/api/views/admin.py
new file mode 100644
index 000000000..5d93aba49
--- /dev/null
+++ b/apiserver/plane/license/api/views/admin.py
@@ -0,0 +1,457 @@
+# Python imports
+from urllib.parse import urlencode, urljoin
+import uuid
+from zxcvbn import zxcvbn
+
+# Django imports
+from django.http import HttpResponseRedirect
+from django.views import View
+from django.core.validators import validate_email
+from django.core.exceptions import ValidationError
+from django.utils import timezone
+from django.contrib.auth.hashers import make_password
+from django.contrib.auth import logout
+
+# Third party imports
+from rest_framework.response import Response
+from rest_framework import status
+from rest_framework.permissions import AllowAny
+
+# Module imports
+from .base import BaseAPIView
+from plane.license.api.permissions import InstanceAdminPermission
+from plane.license.api.serializers import (
+ InstanceAdminMeSerializer,
+ InstanceAdminSerializer,
+)
+from plane.license.models import Instance, InstanceAdmin
+from plane.db.models import User, Profile
+from plane.utils.cache import cache_response, invalidate_cache
+from plane.authentication.utils.login import user_login
+from plane.authentication.utils.host import base_host, user_ip
+from plane.authentication.adapter.error import (
+ AUTHENTICATION_ERROR_CODES,
+ AuthenticationException,
+)
+
+
+class InstanceAdminEndpoint(BaseAPIView):
+ permission_classes = [
+ InstanceAdminPermission,
+ ]
+
+ @invalidate_cache(path="/api/instances/", user=False)
+ # Create an instance admin
+ def post(self, request):
+ email = request.data.get("email", False)
+ role = request.data.get("role", 20)
+
+ if not email:
+ return Response(
+ {"error": "Email is required"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ instance = Instance.objects.first()
+ if instance is None:
+ return Response(
+ {"error": "Instance is not registered yet"},
+ status=status.HTTP_403_FORBIDDEN,
+ )
+
+ # Fetch the user
+ user = User.objects.get(email=email)
+
+ instance_admin = InstanceAdmin.objects.create(
+ instance=instance,
+ user=user,
+ role=role,
+ )
+ serializer = InstanceAdminSerializer(instance_admin)
+ return Response(serializer.data, status=status.HTTP_201_CREATED)
+
+ @cache_response(60 * 60 * 2, user=False)
+ def get(self, request):
+ instance = Instance.objects.first()
+ if instance is None:
+ return Response(
+ {"error": "Instance is not registered yet"},
+ status=status.HTTP_403_FORBIDDEN,
+ )
+ instance_admins = InstanceAdmin.objects.filter(instance=instance)
+ serializer = InstanceAdminSerializer(instance_admins, many=True)
+ return Response(serializer.data, status=status.HTTP_200_OK)
+
+ @invalidate_cache(path="/api/instances/", user=False)
+ def delete(self, request, pk):
+ instance = Instance.objects.first()
+ InstanceAdmin.objects.filter(instance=instance, pk=pk).delete()
+ return Response(status=status.HTTP_204_NO_CONTENT)
+
+
+class InstanceAdminSignUpEndpoint(View):
+ permission_classes = [
+ AllowAny,
+ ]
+
+ @invalidate_cache(path="/api/instances/", user=False)
+ def post(self, request):
+ # Check instance first
+ instance = Instance.objects.first()
+ if instance is None:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INSTANCE_NOT_CONFIGURED"
+ ],
+ error_message="INSTANCE_NOT_CONFIGURED",
+ )
+ url = urljoin(
+ base_host(request=request, is_admin=True),
+ "?" + urlencode(exc.get_error_dict()),
+ )
+ return HttpResponseRedirect(url)
+
+ # check if the instance has already an admin registered
+ if InstanceAdmin.objects.first():
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["ADMIN_ALREADY_EXIST"],
+ error_message="ADMIN_ALREADY_EXIST",
+ )
+ url = urljoin(
+ base_host(request=request, is_admin=True),
+ "?" + urlencode(exc.get_error_dict()),
+ )
+ return HttpResponseRedirect(url)
+
+ # Get the email and password from all the user
+ email = request.POST.get("email", False)
+ password = request.POST.get("password", False)
+ first_name = request.POST.get("first_name", False)
+ last_name = request.POST.get("last_name", "")
+ company_name = request.POST.get("company_name", "")
+ is_telemetry_enabled = request.POST.get("is_telemetry_enabled", True)
+
+ # return error if the email and password is not present
+ if not email or not password or not first_name:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME"
+ ],
+ error_message="REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME",
+ payload={
+ "email": email,
+ "first_name": first_name,
+ "last_name": last_name,
+ "company_name": company_name,
+ "is_telemetry_enabled": is_telemetry_enabled,
+ },
+ )
+ url = urljoin(
+ base_host(request=request, is_admin=True),
+ "?" + urlencode(exc.get_error_dict()),
+ )
+ return HttpResponseRedirect(url)
+
+ # Validate the email
+ email = email.strip().lower()
+ try:
+ validate_email(email)
+ except ValidationError:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_ADMIN_EMAIL"],
+ error_message="INVALID_ADMIN_EMAIL",
+ payload={
+ "email": email,
+ "first_name": first_name,
+ "last_name": last_name,
+ "company_name": company_name,
+ "is_telemetry_enabled": is_telemetry_enabled,
+ },
+ )
+ url = urljoin(
+ base_host(request=request, is_admin=True),
+ "?" + urlencode(exc.get_error_dict()),
+ )
+ return HttpResponseRedirect(url)
+
+ # Check if already a user exists or not
+ # Existing user
+ if User.objects.filter(email=email).exists():
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "ADMIN_USER_ALREADY_EXIST"
+ ],
+ error_message="ADMIN_USER_ALREADY_EXIST",
+ payload={
+ "email": email,
+ "first_name": first_name,
+ "last_name": last_name,
+ "company_name": company_name,
+ "is_telemetry_enabled": is_telemetry_enabled,
+ },
+ )
+ url = urljoin(
+ base_host(request=request, is_admin=True),
+ "?" + urlencode(exc.get_error_dict()),
+ )
+ return HttpResponseRedirect(url)
+ else:
+
+ results = zxcvbn(password)
+ if results["score"] < 3:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INVALID_ADMIN_PASSWORD"
+ ],
+ error_message="INVALID_ADMIN_PASSWORD",
+ payload={
+ "email": email,
+ "first_name": first_name,
+ "last_name": last_name,
+ "company_name": company_name,
+ "is_telemetry_enabled": is_telemetry_enabled,
+ },
+ )
+ url = urljoin(
+ base_host(request=request, is_admin=True),
+ "?" + urlencode(exc.get_error_dict()),
+ )
+ return HttpResponseRedirect(url)
+
+ user = User.objects.create(
+ first_name=first_name,
+ last_name=last_name,
+ email=email,
+ username=uuid.uuid4().hex,
+ password=make_password(password),
+ is_password_autoset=False,
+ )
+ _ = Profile.objects.create(user=user, company_name=company_name)
+ # 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(
+ user=user,
+ instance=instance,
+ )
+ # Make the setup flag True
+ instance.is_setup_done = True
+ instance.instance_name = company_name
+ instance.is_telemetry_enabled = is_telemetry_enabled
+ instance.save()
+
+ # get tokens for user
+ user_login(request=request, user=user, is_admin=True)
+ url = urljoin(base_host(request=request, is_admin=True), "general")
+ return HttpResponseRedirect(url)
+
+
+class InstanceAdminSignInEndpoint(View):
+ permission_classes = [
+ AllowAny,
+ ]
+
+ @invalidate_cache(path="/api/instances/", user=False)
+ def post(self, request):
+ # Check instance first
+ instance = Instance.objects.first()
+ if instance is None:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INSTANCE_NOT_CONFIGURED"
+ ],
+ error_message="INSTANCE_NOT_CONFIGURED",
+ )
+ url = urljoin(
+ base_host(request=request, is_admin=True),
+ "?" + urlencode(exc.get_error_dict()),
+ )
+ return HttpResponseRedirect(url)
+
+ # Get email and password
+ email = request.POST.get("email", False)
+ password = request.POST.get("password", False)
+
+ # return error if the email and password is not present
+ if not email or not password:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "REQUIRED_ADMIN_EMAIL_PASSWORD"
+ ],
+ error_message="REQUIRED_ADMIN_EMAIL_PASSWORD",
+ payload={
+ "email": email,
+ },
+ )
+ url = urljoin(
+ base_host(request=request, is_admin=True),
+ "?" + urlencode(exc.get_error_dict()),
+ )
+ return HttpResponseRedirect(url)
+
+ # Validate the email
+ email = email.strip().lower()
+ try:
+ validate_email(email)
+ except ValidationError:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_ADMIN_EMAIL"],
+ error_message="INVALID_ADMIN_EMAIL",
+ payload={
+ "email": email,
+ },
+ )
+ url = urljoin(
+ base_host(request=request, is_admin=True),
+ "?" + urlencode(exc.get_error_dict()),
+ )
+ return HttpResponseRedirect(url)
+
+ # Fetch the user
+ user = User.objects.filter(email=email).first()
+
+ # is_active
+ if not user.is_active:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "ADMIN_USER_DEACTIVATED"
+ ],
+ error_message="ADMIN_USER_DEACTIVATED",
+ )
+ url = urljoin(
+ base_host(request=request, is_admin=True),
+ "?" + urlencode(exc.get_error_dict()),
+ )
+ return HttpResponseRedirect(url)
+
+ # Error out if the user is not present
+ if not user:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "ADMIN_USER_DOES_NOT_EXIST"
+ ],
+ error_message="ADMIN_USER_DOES_NOT_EXIST",
+ payload={
+ "email": email,
+ },
+ )
+ url = urljoin(
+ base_host(request=request, is_admin=True),
+ "?" + urlencode(exc.get_error_dict()),
+ )
+ return HttpResponseRedirect(url)
+
+ # Check password of the user
+ if not user.check_password(password):
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "ADMIN_AUTHENTICATION_FAILED"
+ ],
+ error_message="ADMIN_AUTHENTICATION_FAILED",
+ payload={
+ "email": email,
+ },
+ )
+ url = urljoin(
+ base_host(request=request, is_admin=True),
+ "?" + urlencode(exc.get_error_dict()),
+ )
+ return HttpResponseRedirect(url)
+
+ # Check if the user is an instance admin
+ if not InstanceAdmin.objects.filter(instance=instance, user=user):
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "ADMIN_AUTHENTICATION_FAILED"
+ ],
+ error_message="ADMIN_AUTHENTICATION_FAILED",
+ payload={
+ "email": email,
+ },
+ )
+ url = urljoin(
+ base_host(request=request, is_admin=True),
+ "?" + urlencode(exc.get_error_dict()),
+ )
+ return HttpResponseRedirect(url)
+ # 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()
+
+ # get tokens for user
+ user_login(request=request, user=user, is_admin=True)
+ url = urljoin(base_host(request=request, is_admin=True), "general")
+ return HttpResponseRedirect(url)
+
+
+class InstanceAdminUserMeEndpoint(BaseAPIView):
+
+ permission_classes = [
+ InstanceAdminPermission,
+ ]
+
+ def get(self, request):
+ serializer = InstanceAdminMeSerializer(request.user)
+ return Response(
+ serializer.data,
+ status=status.HTTP_200_OK,
+ )
+
+
+class InstanceAdminUserSessionEndpoint(BaseAPIView):
+
+ permission_classes = [
+ AllowAny,
+ ]
+
+ def get(self, request):
+ if (
+ request.user.is_authenticated
+ and InstanceAdmin.objects.filter(user=request.user).exists()
+ ):
+ serializer = InstanceAdminMeSerializer(request.user)
+ data = {"is_authenticated": True}
+ data["user"] = serializer.data
+ return Response(
+ data,
+ status=status.HTTP_200_OK,
+ )
+ else:
+ return Response(
+ {"is_authenticated": False}, status=status.HTTP_200_OK
+ )
+
+
+class InstanceAdminSignOutEndpoint(View):
+
+ permission_classes = [
+ InstanceAdminPermission,
+ ]
+
+ def post(self, request):
+ # Get user
+ try:
+ user = User.objects.get(pk=request.user.id)
+ user.last_logout_ip = user_ip(request=request)
+ user.last_logout_time = timezone.now()
+ user.save()
+ # Log the user out
+ logout(request)
+ url = urljoin(base_host(request=request, is_admin=True))
+ return HttpResponseRedirect(url)
+ except Exception:
+ return HttpResponseRedirect(
+ base_host(request=request, is_admin=True)
+ )
diff --git a/apiserver/plane/license/api/views/base.py b/apiserver/plane/license/api/views/base.py
new file mode 100644
index 000000000..7e367f941
--- /dev/null
+++ b/apiserver/plane/license/api/views/base.py
@@ -0,0 +1,132 @@
+# Python imports
+import zoneinfo
+from django.conf import settings
+from django.core.exceptions import ObjectDoesNotExist, ValidationError
+from django.db import IntegrityError
+
+# Django imports
+from django.utils import timezone
+from django_filters.rest_framework import DjangoFilterBackend
+
+# Third part imports
+from rest_framework import status
+from rest_framework.filters import SearchFilter
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
+# Module imports
+from plane.license.api.permissions import InstanceAdminPermission
+from plane.authentication.session import BaseSessionAuthentication
+from plane.utils.exception_logger import log_exception
+from plane.utils.paginator import BasePaginator
+
+
+class TimezoneMixin:
+ """
+ This enables timezone conversion according
+ to the user set timezone
+ """
+
+ def initial(self, request, *args, **kwargs):
+ super().initial(request, *args, **kwargs)
+ if request.user.is_authenticated:
+ timezone.activate(zoneinfo.ZoneInfo(request.user.user_timezone))
+ else:
+ timezone.deactivate()
+
+
+class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
+ permission_classes = [
+ InstanceAdminPermission,
+ ]
+
+ filter_backends = (
+ DjangoFilterBackend,
+ SearchFilter,
+ )
+
+ authentication_classes = [
+ BaseSessionAuthentication,
+ ]
+
+ filterset_fields = []
+
+ search_fields = []
+
+ def filter_queryset(self, queryset):
+ for backend in list(self.filter_backends):
+ queryset = backend().filter_queryset(self.request, queryset, self)
+ return queryset
+
+ def handle_exception(self, exc):
+ """
+ Handle any exception that occurs, by returning an appropriate response,
+ or re-raising the error.
+ """
+ try:
+ response = super().handle_exception(exc)
+ return response
+ except Exception as e:
+ if isinstance(e, IntegrityError):
+ return Response(
+ {"error": "The payload is not valid"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ if isinstance(e, ValidationError):
+ return Response(
+ {"error": "Please provide valid detail"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ if isinstance(e, ObjectDoesNotExist):
+ return Response(
+ {"error": "The required object does not exist."},
+ status=status.HTTP_404_NOT_FOUND,
+ )
+
+ if isinstance(e, KeyError):
+ return Response(
+ {"error": "The required key does not exist."},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ log_exception(e)
+ return Response(
+ {"error": "Something went wrong please try again later"},
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ )
+
+ def dispatch(self, request, *args, **kwargs):
+ try:
+ response = super().dispatch(request, *args, **kwargs)
+
+ if settings.DEBUG:
+ from django.db import connection
+
+ print(
+ f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}"
+ )
+ return response
+
+ except Exception as exc:
+ response = self.handle_exception(exc)
+ return exc
+
+ @property
+ def fields(self):
+ fields = [
+ field
+ for field in self.request.GET.get("fields", "").split(",")
+ if field
+ ]
+ return fields if fields else None
+
+ @property
+ def expand(self):
+ expand = [
+ expand
+ for expand in self.request.GET.get("expand", "").split(",")
+ if expand
+ ]
+ return expand if expand else None
diff --git a/apiserver/plane/license/api/views/configuration.py b/apiserver/plane/license/api/views/configuration.py
new file mode 100644
index 000000000..06f53b753
--- /dev/null
+++ b/apiserver/plane/license/api/views/configuration.py
@@ -0,0 +1,168 @@
+# Python imports
+from smtplib import (
+ SMTPAuthenticationError,
+ SMTPConnectError,
+ SMTPRecipientsRefused,
+ SMTPSenderRefused,
+ SMTPServerDisconnected,
+)
+
+# Django imports
+from django.core.mail import (
+ BadHeaderError,
+ EmailMultiAlternatives,
+ get_connection,
+)
+
+# Third party imports
+from rest_framework import status
+from rest_framework.response import Response
+
+# Module imports
+from .base import BaseAPIView
+from plane.license.api.permissions import InstanceAdminPermission
+from plane.license.models import InstanceConfiguration
+from plane.license.api.serializers import InstanceConfigurationSerializer
+from plane.license.utils.encryption import encrypt_data
+from plane.utils.cache import cache_response, invalidate_cache
+from plane.license.utils.instance_value import (
+ get_email_configuration,
+)
+
+
+class InstanceConfigurationEndpoint(BaseAPIView):
+ permission_classes = [
+ InstanceAdminPermission,
+ ]
+
+ @cache_response(60 * 60 * 2, user=False)
+ def get(self, request):
+ instance_configurations = InstanceConfiguration.objects.all()
+ serializer = InstanceConfigurationSerializer(
+ instance_configurations, many=True
+ )
+ return Response(serializer.data, status=status.HTTP_200_OK)
+
+ @invalidate_cache(path="/api/instances/configurations/", user=False)
+ @invalidate_cache(path="/api/instances/", user=False)
+ def patch(self, request):
+ configurations = InstanceConfiguration.objects.filter(
+ key__in=request.data.keys()
+ )
+
+ bulk_configurations = []
+ for configuration in configurations:
+ value = request.data.get(configuration.key, configuration.value)
+ if configuration.is_encrypted:
+ configuration.value = encrypt_data(value)
+ else:
+ configuration.value = value
+ bulk_configurations.append(configuration)
+
+ InstanceConfiguration.objects.bulk_update(
+ bulk_configurations, ["value"], batch_size=100
+ )
+
+ serializer = InstanceConfigurationSerializer(configurations, many=True)
+ return Response(serializer.data, status=status.HTTP_200_OK)
+
+
+class EmailCredentialCheckEndpoint(BaseAPIView):
+
+ def post(self, request):
+ receiver_email = request.data.get("receiver_email", False)
+ if not receiver_email:
+ return Response(
+ {"error": "Receiver email is required"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ (
+ EMAIL_HOST,
+ EMAIL_HOST_USER,
+ EMAIL_HOST_PASSWORD,
+ EMAIL_PORT,
+ EMAIL_USE_TLS,
+ EMAIL_USE_SSL,
+ EMAIL_FROM,
+ ) = get_email_configuration()
+
+ # Configure all the connections
+ connection = get_connection(
+ host=EMAIL_HOST,
+ port=int(EMAIL_PORT),
+ username=EMAIL_HOST_USER,
+ password=EMAIL_HOST_PASSWORD,
+ use_tls=EMAIL_USE_TLS == "1",
+ use_ssl=EMAIL_USE_SSL == "1",
+ )
+ # Prepare email details
+ subject = "Email Notification from Plane"
+ message = (
+ "This is a sample email notification sent from Plane application."
+ )
+ # Send the email
+ try:
+ msg = EmailMultiAlternatives(
+ subject=subject,
+ body=message,
+ from_email=EMAIL_FROM,
+ to=[receiver_email],
+ connection=connection,
+ )
+ msg.send(fail_silently=False)
+ return Response(
+ {"message": "Email successfully sent."},
+ status=status.HTTP_200_OK,
+ )
+ except BadHeaderError:
+ return Response(
+ {"error": "Invalid email header."},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ except SMTPAuthenticationError:
+ return Response(
+ {"error": "Invalid credentials provided"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ except SMTPConnectError:
+ return Response(
+ {"error": "Could not connect with the SMTP server."},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ except SMTPSenderRefused:
+ return Response(
+ {"error": "From address is invalid."},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ except SMTPServerDisconnected:
+ return Response(
+ {"error": "SMTP server disconnected unexpectedly."},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ except SMTPRecipientsRefused:
+ return Response(
+ {"error": "All recipient addresses were refused."},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ except TimeoutError:
+ return Response(
+ {
+ "error": "Timeout error while trying to connect to the SMTP server."
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ except ConnectionError:
+ return Response(
+ {
+ "error": "Network connection error. Please check your internet connection."
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ except Exception:
+ return Response(
+ {
+ "error": "Could not send email. Please check your configuration"
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
diff --git a/apiserver/plane/license/api/views/instance.py b/apiserver/plane/license/api/views/instance.py
index 627904a16..525ab54ec 100644
--- a/apiserver/plane/license/api/views/instance.py
+++ b/apiserver/plane/license/api/views/instance.py
@@ -1,33 +1,30 @@
# Python imports
-import uuid
+import os
# Django imports
-from django.utils import timezone
-from django.contrib.auth.hashers import make_password
-from django.core.validators import validate_email
-from django.core.exceptions import ValidationError
+from django.conf import settings
# Third party imports
from rest_framework import status
-from rest_framework.response import Response
from rest_framework.permissions import AllowAny
-from rest_framework_simplejwt.tokens import RefreshToken
+from rest_framework.response import Response
# Module imports
from plane.app.views import BaseAPIView
-from plane.license.models import Instance, InstanceAdmin, InstanceConfiguration
-from plane.license.api.serializers import (
- InstanceSerializer,
- InstanceAdminSerializer,
- InstanceConfigurationSerializer,
-)
+from plane.db.models import Workspace
from plane.license.api.permissions import (
InstanceAdminPermission,
)
-from plane.db.models import User
-from plane.license.utils.encryption import encrypt_data
+from plane.license.api.serializers import (
+ InstanceSerializer,
+)
+from plane.license.models import Instance
+from plane.license.utils.instance_value import (
+ get_configuration_value,
+)
from plane.utils.cache import cache_response, invalidate_cache
+
class InstanceEndpoint(BaseAPIView):
def get_permissions(self):
if self.request.method == "PATCH":
@@ -41,6 +38,7 @@ class InstanceEndpoint(BaseAPIView):
@cache_response(60 * 60 * 2, user=False)
def get(self, request):
instance = Instance.objects.first()
+
# get the instance
if instance is None:
return Response(
@@ -51,7 +49,109 @@ class InstanceEndpoint(BaseAPIView):
serializer = InstanceSerializer(instance)
data = serializer.data
data["is_activated"] = True
- return Response(data, status=status.HTTP_200_OK)
+ # Get all the configuration
+ (
+ IS_GOOGLE_ENABLED,
+ IS_GITHUB_ENABLED,
+ GITHUB_APP_NAME,
+ EMAIL_HOST,
+ 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": "IS_GOOGLE_ENABLED",
+ "default": os.environ.get("IS_GOOGLE_ENABLED", "0"),
+ },
+ {
+ "key": "IS_GITHUB_ENABLED",
+ "default": os.environ.get("IS_GITHUB_ENABLED", "0"),
+ },
+ {
+ "key": "GITHUB_APP_NAME",
+ "default": os.environ.get("GITHUB_APP_NAME", ""),
+ },
+ {
+ "key": "EMAIL_HOST",
+ "default": os.environ.get("EMAIL_HOST", ""),
+ },
+ {
+ "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", None),
+ },
+ {
+ "key": "POSTHOG_API_KEY",
+ "default": os.environ.get("POSTHOG_API_KEY", None),
+ },
+ {
+ "key": "POSTHOG_HOST",
+ "default": os.environ.get("POSTHOG_HOST", None),
+ },
+ {
+ "key": "UNSPLASH_ACCESS_KEY",
+ "default": os.environ.get("UNSPLASH_ACCESS_KEY", ""),
+ },
+ {
+ "key": "OPENAI_API_KEY",
+ "default": os.environ.get("OPENAI_API_KEY", ""),
+ },
+ ]
+ )
+
+ data = {}
+ # Authentication
+ data["is_google_enabled"] = IS_GOOGLE_ENABLED == "1"
+ data["is_github_enabled"] = IS_GITHUB_ENABLED == "1"
+ data["is_magic_login_enabled"] = ENABLE_MAGIC_LINK_LOGIN == "1"
+ data["is_email_password_enabled"] = ENABLE_EMAIL_PASSWORD == "1"
+
+ # Github app name
+ data["github_app_name"] = str(GITHUB_APP_NAME)
+
+ # Slack client
+ data["slack_client_id"] = SLACK_CLIENT_ID
+
+ # Posthog
+ data["posthog_api_key"] = POSTHOG_API_KEY
+ data["posthog_host"] = POSTHOG_HOST
+
+ # Unsplash
+ data["has_unsplash_configured"] = bool(UNSPLASH_ACCESS_KEY)
+
+ # Open AI settings
+ data["has_openai_configured"] = bool(OPENAI_API_KEY)
+
+ # File size settings
+ data["file_size_limit"] = float(
+ os.environ.get("FILE_SIZE_LIMIT", 5242880)
+ )
+
+ # is smtp configured
+ data["is_smtp_configured"] = bool(EMAIL_HOST)
+
+ # Base URL
+ data["admin_base_url"] = settings.ADMIN_BASE_URL
+ data["space_base_url"] = settings.SPACE_BASE_URL
+ data["app_base_url"] = settings.APP_BASE_URL
+
+ instance_data = serializer.data
+ instance_data["workspaces_exist"] = Workspace.objects.count() > 1
+
+ response_data = {"config": data, "instance": instance_data}
+ return Response(response_data, status=status.HTTP_200_OK)
@invalidate_cache(path="/api/instances/", user=False)
def patch(self, request):
@@ -66,196 +166,6 @@ class InstanceEndpoint(BaseAPIView):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
-class InstanceAdminEndpoint(BaseAPIView):
- permission_classes = [
- InstanceAdminPermission,
- ]
-
- @invalidate_cache(path="/api/instances/", user=False)
- # Create an instance admin
- def post(self, request):
- email = request.data.get("email", False)
- role = request.data.get("role", 20)
-
- if not email:
- return Response(
- {"error": "Email is required"},
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- instance = Instance.objects.first()
- if instance is None:
- return Response(
- {"error": "Instance is not registered yet"},
- status=status.HTTP_403_FORBIDDEN,
- )
-
- # Fetch the user
- user = User.objects.get(email=email)
-
- instance_admin = InstanceAdmin.objects.create(
- instance=instance,
- user=user,
- role=role,
- )
- serializer = InstanceAdminSerializer(instance_admin)
- return Response(serializer.data, status=status.HTTP_201_CREATED)
-
- @cache_response(60 * 60 * 2)
- def get(self, request):
- instance = Instance.objects.first()
- if instance is None:
- return Response(
- {"error": "Instance is not registered yet"},
- status=status.HTTP_403_FORBIDDEN,
- )
- instance_admins = InstanceAdmin.objects.filter(instance=instance)
- serializer = InstanceAdminSerializer(instance_admins, many=True)
- return Response(serializer.data, status=status.HTTP_200_OK)
-
- @invalidate_cache(path="/api/instances/", user=False)
- def delete(self, request, pk):
- instance = Instance.objects.first()
- InstanceAdmin.objects.filter(instance=instance, pk=pk).delete()
- return Response(status=status.HTTP_204_NO_CONTENT)
-
-
-class InstanceConfigurationEndpoint(BaseAPIView):
- permission_classes = [
- InstanceAdminPermission,
- ]
-
- @cache_response(60 * 60 * 2, user=False)
- def get(self, request):
- instance_configurations = InstanceConfiguration.objects.all()
- serializer = InstanceConfigurationSerializer(
- instance_configurations, many=True
- )
- return Response(serializer.data, status=status.HTTP_200_OK)
-
- @invalidate_cache(path="/api/configs/", user=False)
- @invalidate_cache(path="/api/mobile-configs/", user=False)
- def patch(self, request):
- configurations = InstanceConfiguration.objects.filter(
- key__in=request.data.keys()
- )
-
- bulk_configurations = []
- for configuration in configurations:
- value = request.data.get(configuration.key, configuration.value)
- if configuration.is_encrypted:
- configuration.value = encrypt_data(value)
- else:
- configuration.value = value
- bulk_configurations.append(configuration)
-
- InstanceConfiguration.objects.bulk_update(
- bulk_configurations, ["value"], batch_size=100
- )
-
- serializer = InstanceConfigurationSerializer(configurations, many=True)
- return Response(serializer.data, status=status.HTTP_200_OK)
-
-
-def get_tokens_for_user(user):
- refresh = RefreshToken.for_user(user)
- return (
- str(refresh.access_token),
- str(refresh),
- )
-
-
-class InstanceAdminSignInEndpoint(BaseAPIView):
- permission_classes = [
- AllowAny,
- ]
-
- @invalidate_cache(path="/api/instances/", user=False)
- def post(self, request):
- # Check instance first
- instance = Instance.objects.first()
- if instance is None:
- return Response(
- {"error": "Instance is not configured"},
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- # check if the instance is already activated
- if InstanceAdmin.objects.first():
- return Response(
- {"error": "Admin for this instance is already registered"},
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- # Get the email and password from all the user
- email = request.data.get("email", False)
- password = request.data.get("password", False)
-
- # return error if the email and password is not present
- if not email or not password:
- return Response(
- {"error": "Email and password are required"},
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- # Validate the email
- email = email.strip().lower()
- try:
- validate_email(email)
- except ValidationError:
- return Response(
- {"error": "Please provide a valid email address."},
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- # Check if already a user exists or not
- user = User.objects.filter(email=email).first()
-
- # Existing user
- if user:
- # Check user password
- if not user.check_password(password):
- return Response(
- {
- "error": "Sorry, we could not find a user with the provided credentials. Please try again."
- },
- status=status.HTTP_403_FORBIDDEN,
- )
- else:
- user = User.objects.create(
- 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(
- user=user,
- instance=instance,
- )
- # Make the setup flag True
- instance.is_setup_done = True
- instance.save()
-
- # get tokens for user
- access_token, refresh_token = get_tokens_for_user(user)
- data = {
- "access_token": access_token,
- "refresh_token": refresh_token,
- }
- return Response(data, status=status.HTTP_200_OK)
-
-
class SignUpScreenVisitedEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
diff --git a/apiserver/plane/license/management/commands/configure_instance.py b/apiserver/plane/license/management/commands/configure_instance.py
index 1bb103113..5a6eadc2e 100644
--- a/apiserver/plane/license/management/commands/configure_instance.py
+++ b/apiserver/plane/license/management/commands/configure_instance.py
@@ -13,6 +13,7 @@ class Command(BaseCommand):
def handle(self, *args, **options):
from plane.license.utils.encryption import encrypt_data
+ from plane.license.utils.instance_value import get_configuration_value
config_keys = [
# Authentication Settings
@@ -40,6 +41,12 @@ class Command(BaseCommand):
"category": "GOOGLE",
"is_encrypted": False,
},
+ {
+ "key": "GOOGLE_CLIENT_SECRET",
+ "value": os.environ.get("GOOGLE_CLIENT_SECRET"),
+ "category": "GOOGLE",
+ "is_encrypted": True,
+ },
{
"key": "GITHUB_CLIENT_ID",
"value": os.environ.get("GITHUB_CLIENT_ID"),
@@ -137,3 +144,80 @@ class Command(BaseCommand):
f"{obj.key} configuration already exists"
)
)
+
+ keys = ["IS_GOOGLE_ENABLED", "IS_GITHUB_ENABLED"]
+ if not InstanceConfiguration.objects.filter(key__in=keys).exists():
+ for key in keys:
+ if key == "IS_GOOGLE_ENABLED":
+ GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET = (
+ get_configuration_value(
+ [
+ {
+ "key": "GOOGLE_CLIENT_ID",
+ "default": os.environ.get(
+ "GOOGLE_CLIENT_ID", ""
+ ),
+ },
+ {
+ "key": "GOOGLE_CLIENT_SECRET",
+ "default": os.environ.get(
+ "GOOGLE_CLIENT_SECRET", "0"
+ ),
+ },
+ ]
+ )
+ )
+ if bool(GOOGLE_CLIENT_ID) and bool(GOOGLE_CLIENT_SECRET):
+ value = "1"
+ else:
+ value = "0"
+ InstanceConfiguration.objects.create(
+ key=key,
+ value=value,
+ category="AUTHENTICATION",
+ is_encrypted=False,
+ )
+ self.stdout.write(
+ self.style.SUCCESS(
+ f"{key} loaded with value from environment variable."
+ )
+ )
+ if key == "IS_GITHUB_ENABLED":
+ GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET = (
+ get_configuration_value(
+ [
+ {
+ "key": "GITHUB_CLIENT_ID",
+ "default": os.environ.get(
+ "GITHUB_CLIENT_ID", ""
+ ),
+ },
+ {
+ "key": "GITHUB_CLIENT_SECRET",
+ "default": os.environ.get(
+ "GITHUB_CLIENT_SECRET", "0"
+ ),
+ },
+ ]
+ )
+ )
+ if bool(GITHUB_CLIENT_ID) and bool(GITHUB_CLIENT_SECRET):
+ value = "1"
+ else:
+ value = "0"
+ InstanceConfiguration.objects.create(
+ key="IS_GITHUB_ENABLED",
+ value=value,
+ category="AUTHENTICATION",
+ is_encrypted=False,
+ )
+ self.stdout.write(
+ self.style.SUCCESS(
+ f"{key} loaded with value from environment variable."
+ )
+ )
+ else:
+ for key in keys:
+ self.stdout.write(
+ self.style.WARNING(f"{key} configuration already exists")
+ )
diff --git a/apiserver/plane/license/urls.py b/apiserver/plane/license/urls.py
index e6315e021..b4f19e52c 100644
--- a/apiserver/plane/license/urls.py
+++ b/apiserver/plane/license/urls.py
@@ -1,11 +1,16 @@
from django.urls import path
from plane.license.api.views import (
- InstanceEndpoint,
+ EmailCredentialCheckEndpoint,
InstanceAdminEndpoint,
- InstanceConfigurationEndpoint,
InstanceAdminSignInEndpoint,
+ InstanceAdminSignUpEndpoint,
+ InstanceConfigurationEndpoint,
+ InstanceEndpoint,
SignUpScreenVisitedEndpoint,
+ InstanceAdminUserMeEndpoint,
+ InstanceAdminSignOutEndpoint,
+ InstanceAdminUserSessionEndpoint,
)
urlpatterns = [
@@ -19,6 +24,21 @@ urlpatterns = [
InstanceAdminEndpoint.as_view(),
name="instance-admins",
),
+ path(
+ "admins/me/",
+ InstanceAdminUserMeEndpoint.as_view(),
+ name="instance-admins",
+ ),
+ path(
+ "admins/session/",
+ InstanceAdminUserSessionEndpoint.as_view(),
+ name="instance-admin-session",
+ ),
+ path(
+ "admins/sign-out/",
+ InstanceAdminSignOutEndpoint.as_view(),
+ name="instance-admins",
+ ),
path(
"admins//",
InstanceAdminEndpoint.as_view(),
@@ -34,9 +54,19 @@ urlpatterns = [
InstanceAdminSignInEndpoint.as_view(),
name="instance-admin-sign-in",
),
+ path(
+ "admins/sign-up/",
+ InstanceAdminSignUpEndpoint.as_view(),
+ name="instance-admin-sign-in",
+ ),
path(
"admins/sign-up-screen-visited/",
SignUpScreenVisitedEndpoint.as_view(),
name="instance-sign-up",
),
+ path(
+ "email-credentials-check/",
+ EmailCredentialCheckEndpoint.as_view(),
+ name="email-credential-check",
+ ),
]
diff --git a/apiserver/plane/license/utils/encryption.py b/apiserver/plane/license/utils/encryption.py
index 11bd9000e..6781605dd 100644
--- a/apiserver/plane/license/utils/encryption.py
+++ b/apiserver/plane/license/utils/encryption.py
@@ -3,6 +3,8 @@ import hashlib
from django.conf import settings
from cryptography.fernet import Fernet
+from plane.utils.exception_logger import log_exception
+
def derive_key(secret_key):
# Use a key derivation function to get a suitable encryption key
@@ -12,21 +14,29 @@ def derive_key(secret_key):
# Encrypt data
def encrypt_data(data):
- if data:
- cipher_suite = Fernet(derive_key(settings.SECRET_KEY))
- encrypted_data = cipher_suite.encrypt(data.encode())
- return encrypted_data.decode() # Convert bytes to string
- else:
+ try:
+ if data:
+ cipher_suite = Fernet(derive_key(settings.SECRET_KEY))
+ encrypted_data = cipher_suite.encrypt(data.encode())
+ return encrypted_data.decode() # Convert bytes to string
+ else:
+ return ""
+ except Exception as e:
+ log_exception(e)
return ""
# Decrypt data
def decrypt_data(encrypted_data):
- if encrypted_data:
- cipher_suite = Fernet(derive_key(settings.SECRET_KEY))
- decrypted_data = cipher_suite.decrypt(
- encrypted_data.encode()
- ) # Convert string back to bytes
- return decrypted_data.decode()
- else:
+ try:
+ if encrypted_data:
+ cipher_suite = Fernet(derive_key(settings.SECRET_KEY))
+ decrypted_data = cipher_suite.decrypt(
+ encrypted_data.encode()
+ ) # Convert string back to bytes
+ return decrypted_data.decode()
+ else:
+ return ""
+ except Exception as e:
+ log_exception(e)
return ""
diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py
index 06c6778d9..853478c75 100644
--- a/apiserver/plane/settings/common.py
+++ b/apiserver/plane/settings/common.py
@@ -3,7 +3,6 @@
# Python imports
import os
import ssl
-from datetime import timedelta
from urllib.parse import urlparse
import certifi
@@ -45,10 +44,9 @@ INSTALLED_APPS = [
"plane.middleware",
"plane.license",
"plane.api",
+ "plane.authentication",
# Third-party things
"rest_framework",
- "rest_framework.authtoken",
- "rest_framework_simplejwt.token_blacklist",
"corsheaders",
"django_celery_beat",
"storages",
@@ -58,7 +56,7 @@ INSTALLED_APPS = [
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.security.SecurityMiddleware",
- "django.contrib.sessions.middleware.SessionMiddleware",
+ "plane.authentication.middleware.session.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
@@ -71,7 +69,7 @@ MIDDLEWARE = [
# Rest Framework settings
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
- "rest_framework_simplejwt.authentication.JWTAuthentication",
+ "rest_framework.authentication.SessionAuthentication",
),
"DEFAULT_PERMISSION_CLASSES": (
"rest_framework.permissions.IsAuthenticated",
@@ -80,6 +78,7 @@ REST_FRAMEWORK = {
"DEFAULT_FILTER_BACKENDS": (
"django_filters.rest_framework.DjangoFilterBackend",
),
+ "EXCEPTION_HANDLER": "plane.authentication.adapter.exception.auth_exception_handler",
}
# Django Auth Backend
@@ -109,9 +108,6 @@ TEMPLATES = [
},
]
-# Cookie Settings
-SESSION_COOKIE_SECURE = True
-CSRF_COOKIE_SECURE = True
# CORS Settings
CORS_ALLOW_CREDENTIALS = True
@@ -122,8 +118,14 @@ cors_allowed_origins = [
]
if cors_allowed_origins:
CORS_ALLOWED_ORIGINS = cors_allowed_origins
+ secure_origins = (
+ False
+ if [origin for origin in cors_allowed_origins if "http:" in origin]
+ else True
+ )
else:
CORS_ALLOW_ALL_ORIGINS = True
+ secure_origins = False
# Application Settings
WSGI_APPLICATION = "plane.wsgi.application"
@@ -149,6 +151,7 @@ else:
"USER": os.environ.get("POSTGRES_USER"),
"PASSWORD": os.environ.get("POSTGRES_PASSWORD"),
"HOST": os.environ.get("POSTGRES_HOST"),
+ "PORT": os.environ.get("POSTGRES_PORT", "5432"),
}
}
@@ -246,35 +249,6 @@ if AWS_S3_ENDPOINT_URL:
AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:"
-# JWT Auth Configuration
-SIMPLE_JWT = {
- "ACCESS_TOKEN_LIFETIME": timedelta(minutes=43200),
- "REFRESH_TOKEN_LIFETIME": timedelta(days=43200),
- "ROTATE_REFRESH_TOKENS": False,
- "BLACKLIST_AFTER_ROTATION": False,
- "UPDATE_LAST_LOGIN": False,
- "ALGORITHM": "HS256",
- "SIGNING_KEY": SECRET_KEY,
- "VERIFYING_KEY": None,
- "AUDIENCE": None,
- "ISSUER": None,
- "JWK_URL": None,
- "LEEWAY": 0,
- "AUTH_HEADER_TYPES": ("Bearer",),
- "AUTH_HEADER_NAME": "HTTP_AUTHORIZATION",
- "USER_ID_FIELD": "id",
- "USER_ID_CLAIM": "user_id",
- "USER_AUTHENTICATION_RULE": "rest_framework_simplejwt.authentication.default_user_authentication_rule",
- "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",),
- "TOKEN_TYPE_CLAIM": "token_type",
- "TOKEN_USER_CLASS": "rest_framework_simplejwt.models.TokenUser",
- "JTI_CLAIM": "jti",
- "SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp",
- "SLIDING_TOKEN_LIFETIME": timedelta(minutes=5),
- "SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=1),
-}
-
-
# Celery Configuration
CELERY_TIMEZONE = TIME_ZONE
CELERY_TASK_SERIALIZER = "json"
@@ -293,6 +267,7 @@ CELERY_IMPORTS = (
"plane.bgtasks.exporter_expired_task",
"plane.bgtasks.file_asset_task",
"plane.bgtasks.email_notification_task",
+ "plane.bgtasks.api_logs_task",
# management tasks
"plane.bgtasks.dummy_data_task",
)
@@ -349,3 +324,30 @@ INSTANCE_KEY = os.environ.get(
SKIP_ENV_VAR = os.environ.get("SKIP_ENV_VAR", "1") == "1"
DATA_UPLOAD_MAX_MEMORY_SIZE = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
+
+# Cookie Settings
+SESSION_COOKIE_SECURE = secure_origins
+SESSION_COOKIE_HTTPONLY = True
+SESSION_ENGINE = "plane.db.models.session"
+SESSION_COOKIE_AGE = os.environ.get("SESSION_COOKIE_AGE", 604800)
+SESSION_COOKIE_NAME = "plane-session-id"
+SESSION_COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", None)
+SESSION_SAVE_EVERY_REQUEST = (
+ os.environ.get("SESSION_SAVE_EVERY_REQUEST", "0") == "1"
+)
+
+# Admin Cookie
+ADMIN_SESSION_COOKIE_NAME = "plane-admin-session-id"
+ADMIN_SESSION_COOKIE_AGE = os.environ.get("ADMIN_SESSION_COOKIE_AGE", 3600)
+
+# CSRF cookies
+CSRF_COOKIE_SECURE = secure_origins
+CSRF_COOKIE_HTTPONLY = True
+CSRF_TRUSTED_ORIGINS = cors_allowed_origins
+CSRF_COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", None)
+CSRF_FAILURE_VIEW = "plane.authentication.views.common.csrf_failure"
+
+# Base URLs
+ADMIN_BASE_URL = os.environ.get("ADMIN_BASE_URL", None)
+SPACE_BASE_URL = os.environ.get("SPACE_BASE_URL", None)
+APP_BASE_URL = os.environ.get("APP_BASE_URL") or os.environ.get("WEB_URL")
diff --git a/apiserver/plane/settings/local.py b/apiserver/plane/settings/local.py
index b00684eae..b175e4c83 100644
--- a/apiserver/plane/settings/local.py
+++ b/apiserver/plane/settings/local.py
@@ -13,7 +13,9 @@ MIDDLEWARE += ("debug_toolbar.middleware.DebugToolbarMiddleware",) # noqa
DEBUG_TOOLBAR_PATCH_SETTINGS = False
# Only show emails in console don't send it to smtp
-EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
+EMAIL_BACKEND = os.environ.get(
+ "EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend"
+)
CACHES = {
"default": {
@@ -30,13 +32,6 @@ INTERNAL_IPS = ("127.0.0.1",)
MEDIA_URL = "/uploads/"
MEDIA_ROOT = os.path.join(BASE_DIR, "uploads") # noqa
-CORS_ALLOWED_ORIGINS = [
- "http://localhost:3000",
- "http://127.0.0.1:3000",
- "http://localhost:4000",
- "http://127.0.0.1:4000",
-]
-
LOG_DIR = os.path.join(BASE_DIR, "logs") # noqa
if not os.path.exists(LOG_DIR):
diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py
index c56222c67..806f83aca 100644
--- a/apiserver/plane/settings/production.py
+++ b/apiserver/plane/settings/production.py
@@ -12,8 +12,6 @@ SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
INSTALLED_APPS += ("scout_apm.django",) # noqa
-# Honor the 'X-Forwarded-Proto' header for request.is_secure()
-SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# Scout Settings
SCOUT_MONITOR = os.environ.get("SCOUT_MONITOR", False)
diff --git a/apiserver/plane/space/views/base.py b/apiserver/plane/space/views/base.py
index 023f27bbc..6b18a1546 100644
--- a/apiserver/plane/space/views/base.py
+++ b/apiserver/plane/space/views/base.py
@@ -21,6 +21,7 @@ from rest_framework.viewsets import ModelViewSet
# Module imports
from plane.utils.exception_logger import log_exception
from plane.utils.paginator import BasePaginator
+from plane.authentication.session import BaseSessionAuthentication
class TimezoneMixin:
@@ -49,6 +50,10 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
SearchFilter,
)
+ authentication_classes = [
+ BaseSessionAuthentication,
+ ]
+
filterset_fields = []
search_fields = []
@@ -146,6 +151,10 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
search_fields = []
+ authentication_classes = [
+ BaseSessionAuthentication,
+ ]
+
def filter_queryset(self, queryset):
for backend in list(self.filter_backends):
queryset = backend().filter_queryset(self.request, queryset, self)
diff --git a/apiserver/plane/urls.py b/apiserver/plane/urls.py
index 3b042ea1f..aac6459b3 100644
--- a/apiserver/plane/urls.py
+++ b/apiserver/plane/urls.py
@@ -2,10 +2,9 @@
"""
-from django.urls import path, include, re_path
-from django.views.generic import TemplateView
-
from django.conf import settings
+from django.urls import include, path, re_path
+from django.views.generic import TemplateView
handler404 = "plane.app.views.error_404.custom_404_view"
@@ -15,6 +14,7 @@ urlpatterns = [
path("api/public/", include("plane.space.urls")),
path("api/instances/", include("plane.license.urls")),
path("api/v1/", include("plane.api.urls")),
+ path("auth/", include("plane.authentication.urls")),
path("", include("plane.web.urls")),
]
diff --git a/apiserver/plane/utils/exception_logger.py b/apiserver/plane/utils/exception_logger.py
index f7bb50de2..0938f054b 100644
--- a/apiserver/plane/utils/exception_logger.py
+++ b/apiserver/plane/utils/exception_logger.py
@@ -6,6 +6,7 @@ from sentry_sdk import capture_exception
def log_exception(e):
+ print(e)
# Log the error
logger = logging.getLogger("plane")
logger.error(e)
diff --git a/apiserver/plane/utils/user_timezone_converter.py b/apiserver/plane/utils/user_timezone_converter.py
index 579b96c26..c946cfb27 100644
--- a/apiserver/plane/utils/user_timezone_converter.py
+++ b/apiserver/plane/utils/user_timezone_converter.py
@@ -8,14 +8,14 @@ def user_timezone_converter(queryset, datetime_fields, user_timezone):
if isinstance(queryset, dict):
queryset_values = [queryset]
else:
- queryset_values = list(queryset.values())
+ queryset_values = list(queryset)
# Iterate over the dictionaries in the list
for item in queryset_values:
# Iterate over the datetime fields
for field in datetime_fields:
# Convert the datetime field to the user's timezone
- if item[field]:
+ if field in item and item[field]:
item[field] = item[field].astimezone(user_tz)
# If queryset was a single item, return a single item
diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt
index 2b7d383ba..a6bd2ab50 100644
--- a/apiserver/requirements/base.txt
+++ b/apiserver/requirements/base.txt
@@ -1,37 +1,63 @@
# base requirements
+# django
Django==4.2.11
-psycopg==3.1.12
-djangorestframework==3.14.0
-redis==4.6.0
-django-cors-headers==4.2.0
-whitenoise==6.5.0
-django-allauth==0.55.2
-faker==18.11.2
-django-filter==23.2
-jsonmodels==2.6.0
-djangorestframework-simplejwt==5.3.0
-sentry-sdk==1.30.0
-django-storages==1.14
-django-crum==0.7.9
-google-auth==2.22.0
-google-api-python-client==2.97.0
-django-redis==5.3.0
-uvicorn==0.23.2
-channels==4.0.0
-openai==1.2.4
-slack-sdk==3.21.3
-celery==5.3.4
-django_celery_beat==2.5.0
-psycopg-binary==3.1.12
-psycopg-c==3.1.12
-scout-apm==2.26.1
-openpyxl==3.1.2
-python-json-logger==2.0.7
-beautifulsoup4==4.12.2
+# rest framework
+djangorestframework==3.15.1
+# postgres
+psycopg==3.1.18
+psycopg-binary==3.1.18
+psycopg-c==3.1.18
dj-database-url==2.1.0
-posthog==3.0.2
-cryptography==42.0.4
-lxml==4.9.3
-boto3==1.28.40
-
+# redis
+redis==5.0.4
+django-redis==5.4.0
+# cors
+django-cors-headers==4.3.1
+# celery
+celery==5.4.0
+django_celery_beat==2.6.0
+# file serve
+whitenoise==6.6.0
+# fake data
+faker==25.0.0
+# filters
+django-filter==24.2
+# json model
+jsonmodels==2.7.0
+# sentry
+sentry-sdk==2.0.1
+# storage
+django-storages==1.14.2
+# user management
+django-crum==0.7.9
+# web server
+uvicorn==0.29.0
+# sockets
+channels==4.1.0
+# ai
+openai==1.25.0
+# slack
+slack-sdk==3.27.1
+# apm
+scout-apm==3.1.0
+# xlsx generation
+openpyxl==3.1.2
+# logging
+python-json-logger==2.0.7
+# html parser
+beautifulsoup4==4.12.3
+# analytics
+posthog==3.5.0
+# crypto
+cryptography==42.0.5
+# html validator
+lxml==5.2.1
+# s3
+boto3==1.34.96
+# password validator
+zxcvbn==4.4.28
+# timezone
+pytz==2024.1
+# jwt
+PyJWT==2.8.0
\ No newline at end of file
diff --git a/apiserver/requirements/local.txt b/apiserver/requirements/local.txt
index 426236ed8..02792201b 100644
--- a/apiserver/requirements/local.txt
+++ b/apiserver/requirements/local.txt
@@ -1,3 +1,5 @@
-r base.txt
-
-django-debug-toolbar==4.1.0
\ No newline at end of file
+# debug toolbar
+django-debug-toolbar==4.3.0
+# formatter
+ruff==0.4.2
\ No newline at end of file
diff --git a/apiserver/requirements/production.txt b/apiserver/requirements/production.txt
index bea44fcfe..ed763c0df 100644
--- a/apiserver/requirements/production.txt
+++ b/apiserver/requirements/production.txt
@@ -1,3 +1,3 @@
-r base.txt
-
+# server
gunicorn==22.0.0
diff --git a/apiserver/requirements/test.txt b/apiserver/requirements/test.txt
index d3272191e..1ffc82d00 100644
--- a/apiserver/requirements/test.txt
+++ b/apiserver/requirements/test.txt
@@ -1,4 +1,4 @@
-r base.txt
-
+# test checker
pytest==7.1.2
coverage==6.5.0
\ No newline at end of file
diff --git a/apiserver/runtime.txt b/apiserver/runtime.txt
index cd0aac542..8cf46af5f 100644
--- a/apiserver/runtime.txt
+++ b/apiserver/runtime.txt
@@ -1 +1 @@
-python-3.11.9
\ No newline at end of file
+python-3.12.3
\ No newline at end of file
diff --git a/apiserver/templates/csrf_failure.html b/apiserver/templates/csrf_failure.html
new file mode 100644
index 000000000..b5a58cb02
--- /dev/null
+++ b/apiserver/templates/csrf_failure.html
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+ CSRF Verification Failed
+
+
+
+
+
+
+
+ It looks like your form submission has expired or there was a problem
+ with your request.
+
+
Please try the following:
+
+ Refresh the page and try submitting the form again.
+ Ensure that cookies are enabled in your browser.
+
+
Go to Home Page
+
+
+
+
diff --git a/apiserver/templates/emails/auth/forgot_password.html b/apiserver/templates/emails/auth/forgot_password.html
index a58a8cef7..9df90724f 100644
--- a/apiserver/templates/emails/auth/forgot_password.html
+++ b/apiserver/templates/emails/auth/forgot_password.html
@@ -1,492 +1,47 @@
-
-
+
+
Set a new password to your Plane account
-
-
-
-
-
-
+
+
+
+
+
+
-
-
+
+
-
+
-
+
-
-
+
+
-
-
+
+
-
-
+
+
-
+
-
-
-
+
@@ -501,104 +56,23 @@
-
+
-
-
+
+
-
-
+
+
-
-
+
+
-
+
-
+
-
- Reset your Plane
- account's password
-
+
Reset your Plane account's password
@@ -607,47 +81,11 @@
-
+
-
+
-
- Someone, hopefully you, has
- requested a new password be
- set to your Plane account.
- If it was you, please click
- the button below to reset
- your password.
-
+
Someone, hopefully you, has requested a new password be set to your Plane account. If it was you, please click the button below to reset your password.
@@ -655,119 +93,21 @@
-
-
+
+
-
+
-
+
-
- Ignore this if you didn't
- ask for a new link.
-
+
Ignore this if you didn't ask for a new link.
@@ -784,114 +124,27 @@
-
+
-
+
-
-
+
+
-
-
+
+
-
+
-
-
+
+
-
-
+
-
-
+
+
-
-
+
+
-
-
+
+
-
+
-
-
-
-
-
-
-
-
-
+
+
+
-
-
-
-
+
+
-
- Despite our popularity, we
- are humbly early-stage. We
- are shipping fast, so please
- reach out to us with feature
- requests, major and minor
- nits, and anything else you
- find missing. We read every message , tweet , and conversation
- and update our public roadmap .
-
+
Despite our popularity, we are humbly early-stage. We are shipping fast, so please reach out to us with feature requests, major and minor nits, and anything else you find missing. We read every message , tweet , and conversation and update our public roadmap .
-
-
-
+
-
-
-
-
-
-
-
-
-
+
+
+
@@ -1174,348 +210,57 @@
-