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 1f14f15aa..13c7ca221 100644 --- a/.github/workflows/build-branch.yml +++ b/.github/workflows/build-branch.yml @@ -22,11 +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_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_backend: ${{ steps.changed_files.outputs.backend_any_changed }} - build_proxy: ${{ steps.changed_files.outputs.proxy_any_changed }} + build_web: ${{ steps.changed_files.outputs.web_any_changed }} steps: - id: set_env_variables @@ -54,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' @@ -68,20 +72,16 @@ jobs: - 'yarn.lock' - 'tsconfig.json' - 'turbo.json' - admin: - - admin/** + web: + - web/** - packages/** - 'package.json' - 'yarn.lock' - 'tsconfig.json' - 'turbo.json' - backend: - - apiserver/** - proxy: - - nginx/** - 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: @@ -236,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/feature-deployment.yml b/.github/workflows/feature-deployment.yml index a0a9dc7f1..17eb494c9 100644 --- a/.github/workflows/feature-deployment.yml +++ b/.github/workflows/feature-deployment.yml @@ -5,12 +5,17 @@ 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 admin-build: @@ -35,7 +40,7 @@ jobs: 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}} @@ -53,7 +58,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 @@ -79,7 +84,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 @@ -89,7 +94,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 }} @@ -98,7 +103,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 @@ -134,7 +139,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_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 }} @@ -143,7 +148,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 @@ -172,7 +177,13 @@ jobs: feature-deploy: 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] + needs: + [ + setup-feature-build, + feature-build-web, + feature-build-space, + feature-build-admin, + ] name: Feature Deploy runs-on: ubuntu-latest env: diff --git a/.gitignore b/.gitignore index 3989f4356..6a556d9bf 100644 --- a/.gitignore +++ b/.gitignore @@ -81,3 +81,4 @@ tmp/ ## packages dist .temp/ +deploy/selfhost/plane-app/ \ No newline at end of file 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 index 2278de30f..a82c768a0 100644 --- a/admin/.eslintrc.js +++ b/admin/.eslintrc.js @@ -10,5 +10,43 @@ module.exports = { }, }, }, - rules: {} + 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/Dockerfile.admin b/admin/Dockerfile.admin index 9abc5daef..b2908f356 100644 --- a/admin/Dockerfile.admin +++ b/admin/Dockerfile.admin @@ -1,3 +1,6 @@ +# ***************************************************************************** +# STAGE 1: Build the project +# ***************************************************************************** FROM node:18-alpine AS builder RUN apk add --no-cache libc6-compat WORKDIR /app @@ -7,6 +10,9 @@ 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 @@ -21,13 +27,25 @@ COPY --from=builder /app/out/full/ . COPY turbo.json turbo.json ARG NEXT_PUBLIC_API_BASE_URL="" -ARG NEXT_PUBLIC_DEPLOY_WITH_NGINX=1 - ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL -ENV NEXT_PUBLIC_DEPLOY_WITH_NGINX=$NEXT_PUBLIC_DEPLOY_WITH_NGINX + +ARG NEXT_PUBLIC_WEB_BASE_URL="" +ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL + +ARG NEXT_PUBLIC_SPACE_BASE_URL="/spaces" +ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL + +ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" +ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH + +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 @@ -38,12 +56,17 @@ 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="" -ARG NEXT_PUBLIC_DEPLOY_WITH_NGINX=1 +ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL -ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL -ENV NEXT_PUBLIC_DEPLOY_WITH_NGINX=$NEXT_PUBLIC_DEPLOY_WITH_NGINX +ARG NEXT_PUBLIC_WEB_BASE_URL="" +ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL + +ARG NEXT_PUBLIC_SPACE_BASE_URL="/spaces" +ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL + +ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" +ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH ENV NEXT_TELEMETRY_DISABLED 1 ENV TURBO_TELEMETRY_DISABLED 1 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) => ( + + ))} +
+
+ +
+ + +
+ +
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 index 61df8ebd9..0a0bacac1 100644 --- a/admin/app/ai/layout.tsx +++ b/admin/app/ai/layout.tsx @@ -1,21 +1,11 @@ -"use client"; - import { ReactNode } from "react"; -// layouts -import { AdminLayout } from "@/layouts"; -// lib -import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers"; +import { Metadata } from "next"; +import { AdminLayout } from "@/layouts/admin-layout"; -interface AILayoutProps { - children: ReactNode; +export const metadata: Metadata = { + title: "AI Settings - God Mode", +}; + +export default function AILayout({ children }: { children: ReactNode }) { + return {children}; } - -const AILayout = ({ children }: AILayoutProps) => ( - - - {children} - - -); - -export default AILayout; diff --git a/admin/app/ai/page.tsx b/admin/app/ai/page.tsx index 71af4a5ba..0979bbabe 100644 --- a/admin/app/ai/page.tsx +++ b/admin/app/ai/page.tsx @@ -1,13 +1,14 @@ "use client"; -import useSWR from "swr"; import { observer } from "mobx-react-lite"; +import useSWR from "swr"; import { Loader } from "@plane/ui"; // components import { PageHeader } from "@/components/core"; -import { InstanceAIForm } from "./components"; // hooks -import { useInstance } from "@/hooks"; +import { useInstance } from "@/hooks/store"; +// components +import { InstanceAIForm } from "./form"; const InstanceAIPage = observer(() => { // store 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 ( +
+
+
+
{icon}
+
+
+
+ {name} +
+
+ {description} +
+
+
+
{config}
+
+ ); +}; diff --git a/admin/app/authentication/components/email-config-switch.tsx b/admin/app/authentication/components/email-config-switch.tsx index 0958b3c42..0f09cf82c 100644 --- a/admin/app/authentication/components/email-config-switch.tsx +++ b/admin/app/authentication/components/email-config-switch.tsx @@ -3,11 +3,11 @@ import React from "react"; import { observer } from "mobx-react-lite"; // hooks -import { useInstance } from "@/hooks"; -// ui -import { ToggleSwitch } from "@plane/ui"; -// types import { TInstanceAuthenticationMethodKeys } from "@plane/types"; +import { ToggleSwitch } from "@plane/ui"; +import { useInstance } from "@/hooks/store"; +// ui +// types type Props = { disabled: boolean; 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 index 59760f00d..d76d61f57 100644 --- a/admin/app/authentication/components/index.ts +++ b/admin/app/authentication/components/index.ts @@ -1,3 +1,5 @@ -export * from "./common"; 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 index 92428e494..901cce862 100644 --- a/admin/app/authentication/components/password-config-switch.tsx +++ b/admin/app/authentication/components/password-config-switch.tsx @@ -3,11 +3,11 @@ import React from "react"; import { observer } from "mobx-react-lite"; // hooks -import { useInstance } from "@/hooks"; -// ui -import { ToggleSwitch } from "@plane/ui"; -// types import { TInstanceAuthenticationMethodKeys } from "@plane/types"; +import { ToggleSwitch } from "@plane/ui"; +import { useInstance } from "@/hooks/store"; +// ui +// types type Props = { disabled: boolean; 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) => ( + + ))} +
+
+ + + 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 index 6470f812a..b65b99205 100644 --- a/admin/app/authentication/github/page.tsx +++ b/admin/app/authentication/github/page.tsx @@ -1,22 +1,23 @@ "use client"; import { useState } from "react"; +import { observer } from "mobx-react-lite"; import Image from "next/image"; import { useTheme } from "next-themes"; -import { observer } from "mobx-react-lite"; import useSWR from "swr"; import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui"; // components import { PageHeader } from "@/components/core"; -import { AuthenticationMethodCard } from "../components"; -import { InstanceGithubConfigForm } from "./components"; -// hooks -import { useInstance } from "@/hooks"; // 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 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) => ( + + ))} +
+
+ + + 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 index f7fa6e643..05117dbe3 100644 --- a/admin/app/authentication/google/page.tsx +++ b/admin/app/authentication/google/page.tsx @@ -1,18 +1,19 @@ "use client"; import { useState } from "react"; -import Image from "next/image"; 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"; -import { AuthenticationMethodCard } from "../components"; -import { InstanceGoogleConfigForm } from "./components"; // hooks -import { useInstance } from "@/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 diff --git a/admin/app/authentication/layout.tsx b/admin/app/authentication/layout.tsx index c6f146ff5..64506ddb4 100644 --- a/admin/app/authentication/layout.tsx +++ b/admin/app/authentication/layout.tsx @@ -1,21 +1,11 @@ -"use client"; - import { ReactNode } from "react"; -// layouts -import { AdminLayout } from "@/layouts"; -// lib -import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers"; +import { Metadata } from "next"; +import { AdminLayout } from "@/layouts/admin-layout"; -interface AuthenticationLayoutProps { - children: ReactNode; +export const metadata: Metadata = { + title: "Authentication Settings - God Mode", +}; + +export default function AuthenticationLayout({ children }: { children: ReactNode }) { + return {children}; } - -const AuthenticationLayout = ({ children }: AuthenticationLayoutProps) => ( - - - {children} - - -); - -export default AuthenticationLayout; diff --git a/admin/app/authentication/page.tsx b/admin/app/authentication/page.tsx index 59e405608..25be147ca 100644 --- a/admin/app/authentication/page.tsx +++ b/admin/app/authentication/page.tsx @@ -1,26 +1,31 @@ "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 { observer } from "mobx-react-lite"; import { Mails, KeyRound } from "lucide-react"; -import { Loader, setPromiseToast } from "@plane/ui"; import { TInstanceConfigurationKeys } from "@plane/types"; +import { Loader, setPromiseToast } from "@plane/ui"; // components -import { AuthenticationMethodCard, EmailCodesConfiguration, PasswordLoginConfiguration } from "./components"; -import { GoogleConfiguration } from "./google/components"; -import { GithubConfiguration } from "./github/components"; import { PageHeader } from "@/components/core"; // hooks -import { useInstance } from "@/hooks"; // helpers import { resolveGeneralTheme } from "@/helpers/common.helper"; +import { useInstance } from "@/hooks/store"; // images -import GoogleLogo from "@/public/logos/google-logo.svg"; 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; 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) => ( + + ))} +
+
+
+
+ + +
+
+ ); +}; diff --git a/admin/app/email/layout.tsx b/admin/app/email/layout.tsx index ce1164ead..64f019ec9 100644 --- a/admin/app/email/layout.tsx +++ b/admin/app/email/layout.tsx @@ -1,21 +1,15 @@ -"use client"; - import { ReactNode } from "react"; -// layouts -import { AdminLayout } from "@/layouts"; -// lib -import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers"; +import { Metadata } from "next"; +import { AdminLayout } from "@/layouts/admin-layout"; interface EmailLayoutProps { children: ReactNode; } -const EmailLayout = ({ children }: EmailLayoutProps) => ( - - - {children} - - -); +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 index a3b0bed59..de776b175 100644 --- a/admin/app/email/page.tsx +++ b/admin/app/email/page.tsx @@ -1,13 +1,14 @@ "use client"; -import useSWR from "swr"; import { observer } from "mobx-react-lite"; +import useSWR from "swr"; import { Loader } from "@plane/ui"; // components import { PageHeader } from "@/components/core"; -import { InstanceEmailForm } from "./components"; // hooks -import { useInstance } from "@/hooks"; +import { useInstance } from "@/hooks/store"; +// components +import { InstanceEmailForm } from "./email-config-form"; const InstanceEmailPage = observer(() => { // store 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 && ( + + )} +
+
+
+
+
+
+
+
+ ); +}; 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. + +
+
+
+
+ ( + + )} + /> +
+
+
+ +
+ +
+
+ ); +}); diff --git a/admin/app/general/layout.tsx b/admin/app/general/layout.tsx index 1761f9689..fabbe3640 100644 --- a/admin/app/general/layout.tsx +++ b/admin/app/general/layout.tsx @@ -1,21 +1,11 @@ -"use client"; - import { ReactNode } from "react"; -// layouts -import { AdminLayout } from "@/layouts"; -// lib -import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers"; +import { Metadata } from "next"; +import { AdminLayout } from "@/layouts/admin-layout"; -interface GeneralLayoutProps { - children: ReactNode; +export const metadata: Metadata = { + title: "General Settings - God Mode", +}; + +export default function GeneralLayout({ children }: { children: ReactNode }) { + return {children}; } - -const GeneralLayout = ({ children }: GeneralLayoutProps) => ( - - - {children} - - -); - -export default GeneralLayout; diff --git a/admin/app/general/page.tsx b/admin/app/general/page.tsx index 10429c1c9..bab2a94fc 100644 --- a/admin/app/general/page.tsx +++ b/admin/app/general/page.tsx @@ -1,18 +1,15 @@ "use client"; - import { observer } from "mobx-react-lite"; -// components -import { PageHeader } from "@/components/core"; -import { GeneralConfigurationForm } from "./components"; // hooks -import { useInstance } from "@/hooks"; +import { useInstance } from "@/hooks/store"; +// components +import { GeneralConfigurationForm } from "./form"; -const GeneralPage = observer(() => { +function GeneralPage() { const { instance, instanceAdmins } = useInstance(); - + console.log("instance", instance); return ( <> -
General settings
@@ -22,13 +19,13 @@ const GeneralPage = observer(() => {
- {instance?.instance && instanceAdmins && instanceAdmins?.length > 0 && ( - + {instance && instanceAdmins && ( + )}
); -}); +} -export default GeneralPage; +export default observer(GeneralPage); diff --git a/admin/app/globals.css b/admin/app/globals.css index 4a7599d49..eefcb1b26 100644 --- a/admin/app/globals.css +++ b/admin/app/globals.css @@ -45,8 +45,8 @@ --color-primary-900: 13, 24, 51; --color-background-100: 255, 255, 255; /* primary bg */ - --color-background-90: 250, 250, 250; /* secondary bg */ - --color-background-80: 245, 245, 245; /* tertiary 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 */ @@ -60,66 +60,39 @@ --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), + --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), + --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), + --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), + --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), + --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), + --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), + --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), + --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-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-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-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-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-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); @@ -138,8 +111,8 @@ color-scheme: light !important; --color-background-100: 255, 255, 255; /* primary bg */ - --color-background-90: 250, 250, 250; /* secondary bg */ - --color-background-80: 245, 245, 245; /* tertiary bg */ + --color-background-90: 247, 247, 247; /* secondary bg */ + --color-background-80: 232, 232, 232; /* tertiary bg */ } [data-theme="light"] { @@ -156,26 +129,10 @@ --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% - ); + --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; @@ -233,28 +190,19 @@ [data-theme="dark-contrast"] { color-scheme: dark !important; - --color-background-100: 7, 7, 7; /* primary bg */ - --color-background-90: 11, 11, 11; /* secondary bg */ - --color-background-80: 23, 23, 23; /* tertiary bg */ + --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); + --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"] { @@ -271,21 +219,9 @@ --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% - ); + --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; @@ -362,37 +298,19 @@ --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-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-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-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 */ + --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 */ } } 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 + /> +
+ +
+ +
+
+ ); +}; diff --git a/admin/app/image/layout.tsx b/admin/app/image/layout.tsx index 4a42facfb..18e9343b5 100644 --- a/admin/app/image/layout.tsx +++ b/admin/app/image/layout.tsx @@ -1,21 +1,15 @@ -"use client"; - import { ReactNode } from "react"; -// layouts -import { AdminLayout } from "@/layouts"; -// lib -import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers"; +import { Metadata } from "next"; +import { AdminLayout } from "@/layouts/admin-layout"; interface ImageLayoutProps { children: ReactNode; } -const ImageLayout = ({ children }: ImageLayoutProps) => ( - - - {children} - - -); +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 index 68572c519..5c1b838be 100644 --- a/admin/app/image/page.tsx +++ b/admin/app/image/page.tsx @@ -1,13 +1,14 @@ "use client"; -import useSWR from "swr"; import { observer } from "mobx-react-lite"; +import useSWR from "swr"; import { Loader } from "@plane/ui"; // components import { PageHeader } from "@/components/core"; -import { InstanceImageConfigForm } from "./components"; // hooks -import { useInstance } from "@/hooks"; +import { useInstance } from "@/hooks/store"; +// local +import { InstanceImageConfigForm } from "./form"; const InstanceImagePage = observer(() => { // store diff --git a/admin/app/layout.tsx b/admin/app/layout.tsx index 41d142a83..865eb23f9 100644 --- a/admin/app/layout.tsx +++ b/admin/app/layout.tsx @@ -2,26 +2,41 @@ import { ReactNode } from "react"; import { ThemeProvider } from "next-themes"; +import { SWRConfig } from "swr"; +// constants +import { SWR_CONFIG } from "@/constants/swr-config"; +// helpers +import { ASSET_PREFIX } from "@/helpers/common.helper"; // lib -import { StoreProvider } from "@/lib/store-context"; -import { AppWrapper } from "@/lib/wrappers"; +import { InstanceProvider } from "@/lib/instance-provider"; +import { StoreProvider } from "@/lib/store-provider"; +import { UserProvider } from "@/lib/user-provider"; // styles import "./globals.css"; -interface RootLayoutProps { - children: ReactNode; +function RootLayout({ children }: { children: ReactNode }) { + return ( + + + + + + + + + + + + + + {children} + + + + + + + ); } -const RootLayout = ({ children, ...pageProps }: RootLayoutProps) => ( - - - - - {children} - - - - -); - export default RootLayout; diff --git a/admin/app/page.tsx b/admin/app/page.tsx index 3b19fb3d6..b402fc44d 100644 --- a/admin/app/page.tsx +++ b/admin/app/page.tsx @@ -1,20 +1,30 @@ -"use client"; - -import { useEffect } from "react"; -import { useRouter } from "next/navigation"; +import { Metadata } from "next"; // components -import { PageHeader } from "@/components/core"; +import { InstanceSignInForm } from "@/components/login"; +// layouts +import { DefaultLayout } from "@/layouts/default-layout"; -const RootPage = () => { - const router = useRouter(); - - useEffect(() => router.push("/login"), [router]); - - return ( - <> - - - ); +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 RootPage; +export default async function LoginPage() { + return ( + + + + ); +} diff --git a/admin/components/admin-sidebar/help-section.tsx b/admin/components/admin-sidebar/help-section.tsx index b2cba645d..371bb49d8 100644 --- a/admin/components/admin-sidebar/help-section.tsx +++ b/admin/components/admin-sidebar/help-section.tsx @@ -1,13 +1,14 @@ "use client"; import { FC, useState, useRef } from "react"; -import { Transition } from "@headlessui/react"; +import { observer } from "mobx-react-lite"; import Link from "next/link"; -import { FileText, HelpCircle, 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 { useTheme } from "@/hooks"; -// icons -import { DiscordIcon, GithubIcon } from "@plane/ui"; +import { WEB_BASE_URL } from "@/helpers/common.helper"; +import { useTheme } from "@/hooks/store"; // assets import packageJson from "package.json"; @@ -29,7 +30,7 @@ const helpOptions = [ }, ]; -export const HelpSection: FC = () => { +export const HelpSection: FC = observer(() => { // states const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false); // store @@ -37,40 +38,46 @@ export const HelpSection: FC = () => { // refs const helpOptionsRef = useRef(null); + const redirectionLink = encodeURI(WEB_BASE_URL + "/create-workspace"); + return (
-
- - - +
+ + + + {!isSidebarCollapsed && "Redirect to plane"} + + + + + + + +
@@ -123,4 +130,4 @@ export const HelpSection: FC = () => {
); -}; +}); diff --git a/admin/components/admin-sidebar/root.tsx b/admin/components/admin-sidebar/root.tsx index 3b754d8b2..ff94bf228 100644 --- a/admin/components/admin-sidebar/root.tsx +++ b/admin/components/admin-sidebar/root.tsx @@ -3,10 +3,10 @@ import { FC, useEffect, useRef } from "react"; import { observer } from "mobx-react-lite"; // hooks -import { useTheme } from "@/hooks"; +import { HelpSection, SidebarMenu, SidebarDropdown } from "@/components/admin-sidebar"; +import { useTheme } from "@/hooks/store"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; // components -import { HelpSection, SidebarMenu, SidebarDropdown } from "@/components/admin-sidebar"; export interface IInstanceSidebar {} diff --git a/admin/components/admin-sidebar/sidebar-dropdown.tsx b/admin/components/admin-sidebar/sidebar-dropdown.tsx index 83d1e9bd8..84583e24b 100644 --- a/admin/components/admin-sidebar/sidebar-dropdown.tsx +++ b/admin/components/admin-sidebar/sidebar-dropdown.tsx @@ -1,15 +1,20 @@ "use client"; -import { Fragment } from "react"; -import { useTheme as useNextTheme } from "next-themes"; +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, TOAST_TYPE, setToast } from "@plane/ui"; +import { Avatar } from "@plane/ui"; // hooks -import { useTheme, useUser } from "@/hooks"; +import { API_BASE_URL, cn } from "@/helpers/common.helper"; +import { useTheme, useUser } from "@/hooks/store"; // helpers -import { API_BASE_URL } from "@/helpers/common.helper"; +// services +import { AuthService } from "@/services/auth.service"; + +// service initialization +const authService = new AuthService(); export const SidebarDropdown = observer(() => { // store hooks @@ -17,22 +22,60 @@ export const SidebarDropdown = observer(() => { const { currentUser, signOut } = useUser(); // hooks const { resolvedTheme, setTheme } = useNextTheme(); - - const handleSignOut = async () => { - await signOut().catch(() => - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Failed to sign out. Please try again.", - }) - ); - }; + // 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 + +
+
+
+ + + + Sign out + +
+
+
+ ); + + useEffect(() => { + if (csrfToken === undefined) + authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); + }, [csrfToken]); + return (
@@ -41,9 +84,30 @@ export const SidebarDropdown = observer(() => { isSidebarCollapsed ? "justify-center" : "" }`} > -
- -
+ + +
+ +
+
+ {isSidebarCollapsed && ( + + {getSidebarMenuItems()} + + )} +
{!isSidebarCollapsed && (
@@ -74,38 +138,7 @@ export const SidebarDropdown = observer(() => { leaveFrom="transform opacity-100 scale-100" leaveTo="transform opacity-0 scale-95" > - -
- {currentUser?.email} -
-
- - - Switch to {resolvedTheme === "dark" ? "light" : "dark"} mode - -
-
-
- - - Sign out - -
-
-
+ {getSidebarMenuItems()} )} diff --git a/admin/components/admin-sidebar/sidebar-menu-hamburger-toogle.tsx b/admin/components/admin-sidebar/sidebar-menu-hamburger-toogle.tsx index ba00afa7f..2e8539488 100644 --- a/admin/components/admin-sidebar/sidebar-menu-hamburger-toogle.tsx +++ b/admin/components/admin-sidebar/sidebar-menu-hamburger-toogle.tsx @@ -3,9 +3,9 @@ import { FC } from "react"; import { observer } from "mobx-react-lite"; // hooks -import { useTheme } from "@/hooks"; -// icons import { Menu } from "lucide-react"; +import { useTheme } from "@/hooks/store"; +// icons export const SidebarHamburgerToggle: FC = observer(() => { const { isSidebarCollapsed, toggleSidebar } = useTheme(); diff --git a/admin/components/admin-sidebar/sidebar-menu.tsx b/admin/components/admin-sidebar/sidebar-menu.tsx index e7111aea2..f7c146fa2 100644 --- a/admin/components/admin-sidebar/sidebar-menu.tsx +++ b/admin/components/admin-sidebar/sidebar-menu.tsx @@ -1,14 +1,14 @@ "use client"; +import { observer } from "mobx-react-lite"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { observer } from "mobx-react-lite"; import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react"; import { Tooltip } from "@plane/ui"; // hooks -import { useTheme } from "@/hooks"; -// helpers import { cn } from "@/helpers/common.helper"; +import { useTheme } from "@/hooks/store"; +// helpers const INSTANCE_ADMIN_LINKS = [ { diff --git a/admin/components/auth-header.tsx b/admin/components/auth-header.tsx index 21871aed4..4becf928f 100644 --- a/admin/components/auth-header.tsx +++ b/admin/components/auth-header.tsx @@ -1,16 +1,16 @@ "use client"; import { FC } from "react"; +import { observer } from "mobx-react-lite"; import { usePathname } from "next/navigation"; // mobx -import { observer } from "mobx-react-lite"; // ui import { Settings } from "lucide-react"; // icons import { Breadcrumbs } from "@plane/ui"; // components -import { BreadcrumbLink } from "components/common"; import { SidebarHamburgerToggle } from "@/components/admin-sidebar"; +import { BreadcrumbLink } from "components/common"; export const InstanceHeader: FC = observer(() => { const pathName = usePathname(); diff --git a/admin/components/common/banner.tsx b/admin/components/common/banner.tsx index 13d1583a2..932a0c629 100644 --- a/admin/components/common/banner.tsx +++ b/admin/components/common/banner.tsx @@ -1,5 +1,5 @@ import { FC } from "react"; -import { AlertCircle, CheckCircle } from "lucide-react"; +import { AlertCircle, CheckCircle2 } from "lucide-react"; type TBanner = { type: "success" | "error"; @@ -10,19 +10,21 @@ export const Banner: FC = (props) => { const { type, message } = props; return ( -
+
{type === "error" ? ( - -
-
-

{message}

+
+

{message}

diff --git a/admin/components/common/controller-input.tsx b/admin/components/common/controller-input.tsx index c386e7374..0eb215095 100644 --- a/admin/components/common/controller-input.tsx +++ b/admin/components/common/controller-input.tsx @@ -2,10 +2,12 @@ import React, { useState } from "react"; import { Controller, Control } from "react-hook-form"; -// ui -import { Input } from "@plane/ui"; // icons import { Eye, EyeOff } from "lucide-react"; +// ui +import { Input } from "@plane/ui"; +// helpers +import { cn } from "@/helpers/common.helper"; type Props = { control: Control; @@ -35,7 +37,9 @@ export const ControllerInput: React.FC = (props) => { return (
-

{label}

+

+ {label} {!required && "(optional)"} +

= (props) => { ref={ref} hasError={error} placeholder={placeholder} - className="w-full rounded-md font-medium" + className={cn("w-full rounded-md font-medium", { + "pr-10": type === "password", + })} /> )} /> {type === "password" && (showPassword ? ( ) : ( ))}
- {description &&

{description}

} + {description &&

{description}

}
); }; diff --git a/admin/components/common/copy-field.tsx b/admin/components/common/copy-field.tsx index d6368b6e9..47e1a3364 100644 --- a/admin/components/common/copy-field.tsx +++ b/admin/components/common/copy-field.tsx @@ -2,9 +2,9 @@ import React from "react"; // ui +import { Copy } from "lucide-react"; import { Button, TOAST_TYPE, setToast } from "@plane/ui"; // icons -import { Copy } from "lucide-react"; type Props = { label: string; @@ -40,7 +40,7 @@ export const CopyField: React.FC = (props) => {

{url}

-

{description}

+
{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 && {primaryButton?.text} +
{title}
+ {description &&

{description}

} +
+ {primaryButton && ( + + )} + {secondaryButton} +
+
+
+); diff --git a/admin/components/common/index.ts b/admin/components/common/index.ts index 97248b999..c810cac69 100644 --- a/admin/components/common/index.ts +++ b/admin/components/common/index.ts @@ -4,3 +4,6 @@ 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 ( +
+ logo +
+ ); +}; diff --git a/admin/components/common/password-strength-meter.tsx b/admin/components/common/password-strength-meter.tsx index fabb186f9..004a927b2 100644 --- a/admin/components/common/password-strength-meter.tsx +++ b/admin/components/common/password-strength-meter.tsx @@ -1,10 +1,10 @@ "use client"; // helpers +import { CircleCheck } from "lucide-react"; import { cn } from "@/helpers/common.helper"; import { getPasswordStrength } from "@/helpers/password.helper"; // icons -import { CircleCheck } from "lucide-react"; type Props = { password: string; @@ -43,7 +43,7 @@ export const PasswordStrengthMeter: React.FC = (props: Props) => { ]; return ( -
+
{bars.map((color, index) => (
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/page-header.tsx b/admin/components/core/page-header.tsx index 5b64a8b02..a4b27b92f 100644 --- a/admin/components/core/page-header.tsx +++ b/admin/components/core/page-header.tsx @@ -1,4 +1,4 @@ -import Head from "next/head"; +"use client"; type TPageHeader = { title?: string; @@ -9,9 +9,9 @@ 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 index 373ba7057..56d1933f4 100644 --- a/admin/components/instance/index.ts +++ b/admin/components/instance/index.ts @@ -1 +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 ( +
+
+
+ Plane Logo +

Unable to fetch instance details.

+

+ We were unable to fetch the details of the instance.
+ Fret not, it might just be a connectivity issue. +

+
+
+ +
+
+
+ ); +}; diff --git a/admin/components/instance/instance-not-ready.tsx b/admin/components/instance/instance-not-ready.tsx index 067599021..874013f52 100644 --- a/admin/components/instance/instance-not-ready.tsx +++ b/admin/components/instance/instance-not-ready.tsx @@ -1,8 +1,8 @@ "use client"; import { FC } from "react"; -import Link from "next/link"; import Image from "next/image"; +import Link from "next/link"; import { Button } from "@plane/ui"; // assets import PlaneTakeOffImage from "@/public/images/plane-takeoff.png"; @@ -11,9 +11,9 @@ export const InstanceNotReady: FC = () => (
-

Welcome aboard Plane!

+

Welcome aboard Plane!

Plane Logo -

+

Get started by setting up your instance and workspace

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) && ( + + )} + +
setIsSubmitting(true)} + onError={() => setIsSubmitting(false)} + > + + +
+
+ + handleFormChange("first_name", e.target.value)} + autoFocus + /> +
+
+ + handleFormChange("last_name", e.target.value)} + /> +
+
+ +
+ + handleFormChange("email", e.target.value)} + hasError={errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL ? true : false} + /> + {errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL && errorData.message && ( +

{errorData.message}

+ )} +
+ +
+ + handleFormChange("company_name", e.target.value)} + /> +
+ +
+ +
+ handleFormChange("password", e.target.value)} + hasError={errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD ? true : false} + onFocus={() => setIsPasswordInputFocused(true)} + onBlur={() => setIsPasswordInputFocused(false)} + /> + {showPassword.password ? ( + + ) : ( + + )} +
+ {errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD && errorData.message && ( +

{errorData.message}

+ )} + {isPasswordInputFocused && } +
+ +
+ +
+ handleFormChange("confirm_password", e.target.value)} + placeholder="Confirm password" + className="w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" + onFocus={() => setIsRetryPasswordInputFocused(true)} + onBlur={() => setIsRetryPasswordInputFocused(false)} + /> + {showPassword.retypePassword ? ( + + ) : ( + + )} +
+ {!!formData.confirm_password && + formData.password !== formData.confirm_password && + renderPasswordMatchError && Passwords don{"'"}t match} +
+ +
+
+ handleFormChange("is_telemetry_enabled", !formData.is_telemetry_enabled)} + checked={formData.is_telemetry_enabled} + /> +
+ + + See More + +
+ +
+ +
+
+
+
+ ); +}; 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 && } + +
setIsSubmitting(true)} + onError={() => setIsSubmitting(false)} + > + + +
+ + handleFormChange("email", e.target.value)} + autoFocus + /> +
+ +
+ +
+ handleFormChange("password", e.target.value)} + /> + {showPassword ? ( + + ) : ( + + )} +
+
+
+ +
+
+
+
+ ); +}; 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. +
+
+ + Create workspace + + +
+
+
+ Plane icon +
+
+
+ ); +}); 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/helpers/authentication.helper.tsx b/admin/helpers/authentication.helper.tsx new file mode 100644 index 000000000..6bfccd45e --- /dev/null +++ b/admin/helpers/authentication.helper.tsx @@ -0,0 +1,127 @@ +import { ReactNode } from "react"; +import Link from "next/link"; + +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", +} + +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. +
+ ), + }, +}; + +export const authErrorHandler = ( + errorCode: EAuthenticationErrorCodes, + email?: string | undefined +): TAuthErrorInfo | undefined => { + const bannerAlertErrorCodes = [ + 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, + ]; + + 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 index 3bf03024b..e7aae0698 100644 --- a/admin/helpers/common.helper.ts +++ b/admin/helpers/common.helper.ts @@ -1,7 +1,16 @@ import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; -export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ? process.env.NEXT_PUBLIC_API_BASE_URL : ""; +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 ASSET_PREFIX = ADMIN_BASE_PATH; export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs)); 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 index 92165e2bb..cf2edc39f 100644 --- a/admin/hooks/store/use-instance.tsx +++ b/admin/hooks/store/use-instance.tsx @@ -1,6 +1,6 @@ import { useContext } from "react"; // store -import { StoreContext } from "@/lib/store-context"; +import { StoreContext } from "@/lib/store-provider"; import { IInstanceStore } from "@/store/instance.store"; export const useInstance = (): IInstanceStore => { diff --git a/admin/hooks/store/use-theme.tsx b/admin/hooks/store/use-theme.tsx index dc4f9dbf8..bad89cfee 100644 --- a/admin/hooks/store/use-theme.tsx +++ b/admin/hooks/store/use-theme.tsx @@ -1,6 +1,6 @@ import { useContext } from "react"; // store -import { StoreContext } from "@/lib/store-context"; +import { StoreContext } from "@/lib/store-provider"; import { IThemeStore } from "@/store/theme.store"; export const useTheme = (): IThemeStore => { diff --git a/admin/hooks/store/use-user.tsx b/admin/hooks/store/use-user.tsx index d1e114ae4..823003144 100644 --- a/admin/hooks/store/use-user.tsx +++ b/admin/hooks/store/use-user.tsx @@ -1,6 +1,6 @@ import { useContext } from "react"; // store -import { StoreContext } from "@/lib/store-context"; +import { StoreContext } from "@/lib/store-provider"; import { IUserStore } from "@/store/user.store"; export const useUser = (): IUserStore => { diff --git a/admin/layouts/admin-layout.tsx b/admin/layouts/admin-layout.tsx index 26e7adb77..bcc103217 100644 --- a/admin/layouts/admin-layout.tsx +++ b/admin/layouts/admin-layout.tsx @@ -1,13 +1,38 @@ -import { FC, ReactNode } from "react"; +"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 = (props) => { +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 (
@@ -16,6 +41,7 @@ export const AdminLayout: FC = (props) => {
{children}
+
); -}; +}); diff --git a/admin/layouts/default-layout.tsx b/admin/layouts/default-layout.tsx index a798ae055..1621fc0f8 100644 --- a/admin/layouts/default-layout.tsx +++ b/admin/layouts/default-layout.tsx @@ -2,31 +2,39 @@ import { FC, ReactNode } from "react"; import Image from "next/image"; -import { usePathname } from "next/navigation"; -// logo +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 BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png"; type TDefaultLayout = { children: ReactNode; + withoutBackground?: boolean; }; export const DefaultLayout: FC = (props) => { - const { children } = props; - const pathname = usePathname(); - - console.log("pathname", pathname); + const { children, withoutBackground = false } = props; + // hooks + const { resolvedTheme } = useTheme(); + const patternBackground = resolvedTheme === "dark" ? PlaneBackgroundPatternDark : PlaneBackgroundPattern; return ( -
-
-
-
- Plane Logo +
+
+
+
+ Plane Logo Plane
+ {!withoutBackground && ( +
+ Plane background pattern +
+ )} +
{children}
-
{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.config.js b/admin/next.config.js index 85b87e91f..07f6664af 100644 --- a/admin/next.config.js +++ b/admin/next.config.js @@ -7,7 +7,7 @@ const nextConfig = { images: { unoptimized: true, }, - basePath: process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX === "1" ? "/god-mode" : "", + basePath: process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "", }; module.exports = nextConfig; diff --git a/admin/package.json b/admin/package.json index e0913d094..1e1bc372e 100644 --- a/admin/package.json +++ b/admin/package.json @@ -1,18 +1,20 @@ { "name": "admin", - "version": "0.17.0", + "version": "0.20.0", "private": true, "scripts": { "dev": "turbo run develop", - "develop": "next dev --port 3333", + "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", @@ -22,11 +24,11 @@ "lucide-react": "^0.356.0", "mobx": "^6.12.0", "mobx-react-lite": "^4.0.5", - "next": "^14.1.0", + "next": "^14.2.3", "next-themes": "^0.2.1", - "postcss": "8.4.23", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "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", 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/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/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 index 5de7196aa..fa45c10b7 100644 --- a/admin/services/api.service.ts +++ b/admin/services/api.service.ts @@ -1,4 +1,6 @@ import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios"; +// store +// import { rootStore } from "@/lib/store-context"; export abstract class APIService { protected baseURL: string; @@ -15,13 +17,14 @@ export abstract class APIService { } private setupInterceptors() { - this.axiosInstance.interceptors.response.use( - (response) => response, - (error) => { - if (error.response && error.response.status === 401) window.location.href = "/login"; - return Promise.reject(error.response?.data ?? error); - } - ); + // 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> { diff --git a/admin/services/auth.service.ts b/admin/services/auth.service.ts index c67db9cb6..ef7b7b151 100644 --- a/admin/services/auth.service.ts +++ b/admin/services/auth.service.ts @@ -1,7 +1,7 @@ -// services -import { APIService } from "services/api.service"; // helpers import { API_BASE_URL } from "helpers/common.helper"; +// services +import { APIService } from "services/api.service"; type TCsrfTokenResponse = { csrf_token: string; @@ -19,27 +19,4 @@ export class AuthService extends APIService { throw error; }); } - - async signOut(baseUrl: string): Promise { - await this.requestCSRFToken().then((data) => { - const csrfToken = data?.csrf_token; - - if (!csrfToken) throw Error("CSRF token not found"); - - var form = document.createElement("form"); - var element1 = document.createElement("input"); - - form.method = "POST"; - form.action = `${baseUrl}/api/instances/admins/sign-out/`; - - element1.value = csrfToken; - element1.name = "csrfmiddlewaretoken"; - element1.type = "hidden"; - form.appendChild(element1); - - document.body.appendChild(form); - - form.submit(); - }); - } } diff --git a/admin/services/instance.service.ts b/admin/services/instance.service.ts index 519adc9f2..feb94ceea 100644 --- a/admin/services/instance.service.ts +++ b/admin/services/instance.service.ts @@ -1,19 +1,25 @@ -import { APIService } from "services/api.service"; // types -import type { IFormattedInstanceConfiguration, IInstance, IInstanceAdmin, IInstanceConfiguration } from "@plane/types"; +import type { + IFormattedInstanceConfiguration, + IInstance, + IInstanceAdmin, + IInstanceConfiguration, + IInstanceInfo, +} from "@plane/types"; // helpers -import { API_BASE_URL } from "helpers/common.helper"; +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/") + async getInstanceInfo(): Promise { + return this.get("/api/instances/") .then((response) => response.data) .catch((error) => { - throw error; + throw error?.response?.data; }); } @@ -25,8 +31,8 @@ export class InstanceService extends APIService { }); } - async updateInstanceInfo(data: Partial): Promise { - return this.patch, IInstance["instance"]>("/api/instances/", data) + async updateInstanceInfo(data: Partial): Promise { + return this.patch, IInstance>("/api/instances/", data) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; diff --git a/admin/services/user.service.ts b/admin/services/user.service.ts index 9209ec460..bef384daf 100644 --- a/admin/services/user.service.ts +++ b/admin/services/user.service.ts @@ -1,15 +1,25 @@ +// helpers +import { API_BASE_URL } from "helpers/common.helper"; // services import { APIService } from "services/api.service"; // types import type { IUser } from "@plane/types"; -// helpers -import { API_BASE_URL } from "helpers/common.helper"; + +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) diff --git a/admin/store/instance.store.ts b/admin/store/instance.store.ts index fdc46e99b..a99cd808c 100644 --- a/admin/store/instance.store.ts +++ b/admin/store/instance.store.ts @@ -1,34 +1,46 @@ -import { observable, action, computed, makeObservable, runInAction } from "mobx"; import set from "lodash/set"; -import { IInstance, IInstanceAdmin, IInstanceConfiguration, IFormattedInstanceConfiguration } from "@plane/types"; +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"; +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 - fetchInstanceInfo: () => Promise; - updateInstanceInfo: (data: Partial) => Promise; + hydrate: (data: IInstanceInfo) => void; + fetchInstanceInfo: () => Promise; + updateInstanceInfo: (data: Partial) => Promise; fetchInstanceAdmins: () => Promise; fetchInstanceConfigurations: () => Promise; - updateInstanceConfigurations: (data: Partial) => 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 @@ -38,6 +50,7 @@ export class InstanceStore implements IInstanceStore { makeObservable(this, { // observable isLoading: observable.ref, + error: observable.ref, instanceStatus: observable, instance: observable, instanceAdmins: observable, @@ -45,6 +58,7 @@ export class InstanceStore implements IInstanceStore { // computed formattedConfig: computed, // actions + hydrate: action, fetchInstanceInfo: action, fetchInstanceAdmins: action, updateInstanceInfo: action, @@ -55,6 +69,13 @@ export class InstanceStore implements IInstanceStore { 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. @@ -74,15 +95,22 @@ export class InstanceStore implements IInstanceStore { fetchInstanceInfo = async () => { try { if (this.instance === undefined) this.isLoading = true; - const instance = await this.instanceService.getInstanceInfo(); + 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 = instance; + this.instance = instanceInfo.instance; + this.config = instanceInfo.config; }); - return instance; + 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, }; @@ -92,10 +120,10 @@ export class InstanceStore implements IInstanceStore { /** * @description updating instance information - * @param {Partial} data + * @param {Partial} data * @returns void */ - updateInstanceInfo = async (data: Partial) => { + updateInstanceInfo = async (data: Partial) => { try { const instanceResponse = await this.instanceService.updateInstanceInfo(data); if (instanceResponse) { @@ -146,13 +174,15 @@ export class InstanceStore implements IInstanceStore { */ updateInstanceConfigurations = async (data: Partial) => { try { - await this.instanceService.updateInstanceConfigurations(data).then((response) => { - runInAction(() => { - this.instanceConfigurations = this.instanceConfigurations - ? [...this.instanceConfigurations, ...response] - : response; + 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 index aa695f1cf..a3f3b3d5a 100644 --- a/admin/store/theme.store.ts +++ b/admin/store/theme.store.ts @@ -1,35 +1,50 @@ import { action, observable, makeObservable } from "mobx"; // root store -import { RootStore } from "@/store/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; + }; + /** - * Toggle the sidebar collapsed state + * @description Toggle the new user popup modal + */ + toggleNewUserPopup = () => (this.isNewUserPopup = !this.isNewUserPopup); + + /** + * @description Toggle the sidebar collapsed state * @param isCollapsed */ toggleSidebar = (isCollapsed: boolean) => { @@ -39,7 +54,7 @@ export class ThemeStore implements IThemeStore { }; /** - * Sets the user theme and applies it to the platform + * @description Sets the user theme and applies it to the platform * @param currentTheme */ setTheme = async (currentTheme: TTheme) => { diff --git a/admin/store/user.store.ts b/admin/store/user.store.ts index 0a7895e7b..60638f0cd 100644 --- a/admin/store/user.store.ts +++ b/admin/store/user.store.ts @@ -3,11 +3,10 @@ import { IUser } from "@plane/types"; // helpers import { EUserStatus, TUserStatus } from "@/helpers"; // services -import { UserService } from "services/user.service"; +import { AuthService } from "@/services/auth.service"; +import { UserService } from "@/services/user.service"; // root store -import { RootStore } from "@/store/root-store"; -import { AuthService } from "@/services"; -import { API_BASE_URL } from "@/helpers/common.helper"; +import { RootStore } from "@/store/root.store"; export interface IUserStore { // observables @@ -16,8 +15,10 @@ export interface IUserStore { isUserLoggedIn: boolean | undefined; currentUser: IUser | undefined; // fetch actions + hydrate: (data: any) => void; fetchCurrentUser: () => Promise; - signOut: () => Promise; + reset: () => void; + signOut: () => void; } export class UserStore implements IUserStore { @@ -29,8 +30,6 @@ export class UserStore implements IUserStore { // services userService; authService; - // rootStore - rootStore; constructor(private store: RootStore) { makeObservable(this, { @@ -41,12 +40,17 @@ export class UserStore implements IUserStore { currentUser: observable, // action fetchCurrentUser: action, + reset: action, + signOut: action, }); this.userService = new UserService(); this.authService = new AuthService(); - this.rootStore = store; } + hydrate = (data: any) => { + if (data) this.currentUser = data; + }; + /** * @description Fetches the current user * @returns Promise @@ -55,11 +59,20 @@ export class UserStore implements IUserStore { try { if (this.currentUser === undefined) this.isLoading = true; const currentUser = await this.userService.currentUser(); - runInAction(() => { - this.isUserLoggedIn = true; - this.currentUser = currentUser; - this.isLoading = false; - }); + 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; @@ -78,8 +91,14 @@ export class UserStore implements IUserStore { } }; + reset = async () => { + this.isUserLoggedIn = false; + this.currentUser = undefined; + this.isLoading = false; + this.userStatus = undefined; + }; + signOut = async () => { - await this.authService.signOut(API_BASE_URL); - this.rootStore.resetOnSignOut(); + this.store.resetOnSignOut(); }; } 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/package.json b/apiserver/package.json index 2840f6bef..317e82033 100644 --- a/apiserver/package.json +++ b/apiserver/package.json @@ -1,4 +1,4 @@ { "name": "plane-api", - "version": "0.18.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 46a6b6937..a62278b19 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -32,7 +32,6 @@ from plane.api.serializers import ( LabelSerializer, ) from plane.app.permissions import ( - WorkspaceEntityPermission, ProjectEntityPermission, ProjectLitePermission, ProjectMemberPermission, @@ -49,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 @@ -61,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) @@ -92,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( @@ -101,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, @@ -111,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. @@ -653,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 813c1af21..bdcdf6c0d 100644 --- a/apiserver/plane/app/serializers/__init__.py +++ b/apiserver/plane/app/serializers/__init__.py @@ -28,7 +28,6 @@ from .project import ( ProjectMemberSerializer, ProjectMemberInviteSerializer, ProjectIdentifierSerializer, - ProjectFavoriteSerializer, ProjectLiteSerializer, ProjectMemberLiteSerializer, ProjectDeployBoardSerializer, @@ -40,12 +39,10 @@ from .state import StateSerializer, StateLiteSerializer from .view import ( GlobalViewSerializer, IssueViewSerializer, - IssueViewFavoriteSerializer, ) from .cycle import ( CycleSerializer, CycleIssueSerializer, - CycleFavoriteSerializer, CycleWriteSerializer, CycleUserPropertiesSerializer, ) @@ -83,7 +80,6 @@ from .module import ( ModuleSerializer, ModuleIssueSerializer, ModuleLinkSerializer, - ModuleFavoriteSerializer, ModuleUserPropertiesSerializer, ) @@ -96,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/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/user.py b/apiserver/plane/app/urls/user.py index c069467a2..fd18ea87b 100644 --- a/apiserver/plane/app/urls/user.py +++ b/apiserver/plane/app/urls/user.py @@ -11,6 +11,7 @@ from plane.app.views import ( UserEndpoint, UserIssueCompletedGraphEndpoint, UserWorkspaceDashboardEndpoint, + UserSessionEndpoint, ## End User ## Workspaces UserWorkSpacesEndpoint, @@ -29,6 +30,11 @@ urlpatterns = [ ), name="users", ), + path( + "users/session/", + UserSessionEndpoint.as_view(), + name="user-session", + ), path( "users/me/settings/", UserEndpoint.as_view( diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index 0268f673e..bf765e719 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -29,7 +29,7 @@ from .user.base import ( ) -from .base import BaseAPIView, BaseViewSet, WebhookMixin +from .base import BaseAPIView, BaseViewSet from .workspace.base import ( WorkSpaceViewSet, @@ -222,4 +222,4 @@ from .error_404 import custom_404_view from .exporter.base import ExportIssuesEndpoint from .notification.base import MarkAllReadNotificationViewSet -from .user.base import AccountEndpoint, ProfileEndpoint +from .user.base import AccountEndpoint, ProfileEndpoint, UserSessionEndpoint diff --git a/apiserver/plane/app/views/base.py b/apiserver/plane/app/views/base.py index 42cac04fb..8f21f5fe1 100644 --- a/apiserver/plane/app/views/base.py +++ b/apiserver/plane/app/views/base.py @@ -20,7 +20,6 @@ from rest_framework.viewsets import ModelViewSet # Module imports from plane.authentication.session import BaseSessionAuthentication -from plane.bgtasks.webhook_task import send_webhook from plane.utils.exception_logger import log_exception from plane.utils.paginator import BasePaginator @@ -39,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 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 2a5505dd0..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, @@ -38,9 +38,9 @@ from plane.db.models import ( ) 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 @@ -191,6 +191,11 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): "is_draft", "archived_at", ) + datetime_fields = ["created_at", "updated_at"] + issues = user_timezone_converter( + issues, datetime_fields, request.user.user_timezone + ) + return Response(issues, status=status.HTTP_200_OK) def create(self, request, slug, project_id, cycle_id): @@ -249,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 @@ -256,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 d9274ae4f..cc3a343d2 100644 --- a/apiserver/plane/app/views/issue/archive.py +++ b/apiserver/plane/app/views/issue/archive.py @@ -47,7 +47,7 @@ from plane.db.models import ( ) 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 IssueArchiveViewSet(BaseViewSet): permission_classes = [ @@ -239,6 +239,11 @@ class IssueArchiveViewSet(BaseViewSet): "is_draft", "archived_at", ) + datetime_fields = ["created_at", "updated_at"] + issues = user_timezone_converter( + 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 23df58540..fad85b79d 100644 --- a/apiserver/plane/app/views/issue/base.py +++ b/apiserver/plane/app/views/issue/base.py @@ -50,9 +50,10 @@ from plane.db.models import ( Project, ) 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): @@ -241,10 +242,14 @@ class IssueListEndpoint(BaseAPIView): "is_draft", "archived_at", ) + datetime_fields = ["created_at", "updated_at"] + issues = user_timezone_converter( + issues, datetime_fields, request.user.user_timezone + ) return Response(issues, status=status.HTTP_200_OK) -class IssueViewSet(WebhookMixin, BaseViewSet): +class IssueViewSet(BaseViewSet): def get_serializer_class(self): return ( IssueCreateSerializer @@ -440,6 +445,10 @@ class IssueViewSet(WebhookMixin, BaseViewSet): "is_draft", "archived_at", ) + datetime_fields = ["created_at", "updated_at"] + issues = user_timezone_converter( + issues, datetime_fields, request.user.user_timezone + ) return Response(issues, status=status.HTTP_200_OK) def create(self, request, slug, project_id): @@ -503,6 +512,10 @@ class IssueViewSet(WebhookMixin, BaseViewSet): ) .first() ) + datetime_fields = ["created_at", "updated_at"] + issue = user_timezone_converter( + issue, datetime_fields, request.user.user_timezone + ) return Response(issue, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 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 077d7dcaf..610c3c468 100644 --- a/apiserver/plane/app/views/issue/draft.py +++ b/apiserver/plane/app/views/issue/draft.py @@ -45,6 +45,7 @@ from plane.db.models import ( Project, ) from plane.utils.issue_filters import issue_filters +from plane.utils.user_timezone_converter import user_timezone_converter # Module imports from .. import BaseViewSet @@ -229,6 +230,10 @@ class IssueDraftViewSet(BaseViewSet): "is_draft", "archived_at", ) + datetime_fields = ["created_at", "updated_at"] + issues = user_timezone_converter( + issues, datetime_fields, request.user.user_timezone + ) return Response(issues, status=status.HTTP_200_OK) def create(self, request, slug, project_id): diff --git a/apiserver/plane/app/views/issue/sub_issue.py b/apiserver/plane/app/views/issue/sub_issue.py index da479e0e9..2ee4574eb 100644 --- a/apiserver/plane/app/views/issue/sub_issue.py +++ b/apiserver/plane/app/views/issue/sub_issue.py @@ -31,6 +31,7 @@ from plane.db.models import ( IssueAttachment, ) from plane.bgtasks.issue_activites_task import issue_activity +from plane.utils.user_timezone_converter import user_timezone_converter from collections import defaultdict @@ -132,6 +133,10 @@ class SubIssuesEndpoint(BaseAPIView): "is_draft", "archived_at", ) + datetime_fields = ["created_at", "updated_at"] + sub_issues = user_timezone_converter( + sub_issues, datetime_fields, request.user.user_timezone + ) return Response( { "sub_issues": sub_issues, diff --git a/apiserver/plane/app/views/module/archive.py b/apiserver/plane/app/views/module/archive.py index 9c0b6cca3..2cac5f366 100644 --- a/apiserver/plane/app/views/module/archive.py +++ b/apiserver/plane/app/views/module/archive.py @@ -25,13 +25,10 @@ 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 + # Module imports from .. import BaseAPIView @@ -44,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"), ) @@ -199,6 +197,10 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): "updated_at", "archived_at", ) + datetime_fields = ["created_at", "updated_at"] + modules = user_timezone_converter( + modules, datetime_fields, request.user.user_timezone + ) return Response(modules, status=status.HTTP_200_OK) else: queryset = ( diff --git a/apiserver/plane/app/views/module/base.py b/apiserver/plane/app/views/module/base.py index 4cd52b3b1..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,19 +41,19 @@ from plane.bgtasks.issue_activites_task import issue_activity from plane.db.models import ( Issue, Module, - ModuleFavorite, + UserFavorite, ModuleIssue, ModuleLink, ModuleUserProperties, Project, ) from plane.utils.analytics_plot import burndown_plot - -# Module imports -from .. import BaseAPIView, BaseViewSet, WebhookMixin +from plane.utils.user_timezone_converter import user_timezone_converter +from plane.bgtasks.webhook_task import model_activity +from .. import BaseAPIView, BaseViewSet -class ModuleViewSet(WebhookMixin, BaseViewSet): +class ModuleViewSet(BaseViewSet): model = Module permission_classes = [ ProjectEntityPermission, @@ -68,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"), ) @@ -236,6 +237,20 @@ 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 + ) return Response(module, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -277,6 +292,10 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): "created_at", "updated_at", ) + datetime_fields = ["created_at", "updated_at"] + modules = user_timezone_converter( + modules, datetime_fields, request.user.user_timezone + ) return Response(modules, status=status.HTTP_200_OK) def retrieve(self, request, slug, project_id, pk): @@ -418,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 ) @@ -454,6 +476,22 @@ 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 + ) return Response(module, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -516,8 +554,7 @@ class ModuleLinkViewSet(BaseViewSet): class ModuleFavoriteViewSet(BaseViewSet): - serializer_class = ModuleFavoriteSerializer - model = ModuleFavorite + model = UserFavorite def get_queryset(self): return self.filter_queryset( @@ -529,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 d26433340..3e79e7ec7 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, @@ -31,9 +31,9 @@ from plane.db.models import ( ) 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" @@ -150,6 +150,11 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): "is_draft", "archived_at", ) + datetime_fields = ["created_at", "updated_at"] + issues = user_timezone_converter( + issues, datetime_fields, request.user.user_timezone + ) + return Response(issues, status=status.HTTP_200_OK) # create multiple issues inside a module 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 50435e3a8..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"), ) ) ) @@ -185,7 +188,6 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): .annotate( total_issues=Issue.issue_objects.filter( project_id=self.kwargs.get("pk"), - parent__isnull=True, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -204,7 +206,6 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): archived_issues=Issue.objects.filter( project_id=self.kwargs.get("pk"), archived_at__isnull=False, - parent__isnull=True, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -224,7 +225,6 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): draft_issues=Issue.objects.filter( project_id=self.kwargs.get("pk"), is_draft=True, - parent__isnull=True, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -337,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 @@ -367,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"}, @@ -405,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( @@ -537,8 +560,7 @@ class ProjectUserViewsEndpoint(BaseAPIView): class ProjectFavoritesViewSet(BaseViewSet): - serializer_class = ProjectFavoriteSerializer - model = ProjectFavorite + model = UserFavorite def get_queryset(self): return self.filter_queryset( @@ -556,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) @@ -579,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 60823a5a7..5a75f8105 100644 --- a/apiserver/plane/app/views/user/base.py +++ b/apiserver/plane/app/views/user/base.py @@ -1,9 +1,12 @@ # 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 ( @@ -22,10 +25,12 @@ from plane.db.models import ( ProjectMember, User, WorkspaceMember, + WorkspaceMemberInvite, ) 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): @@ -150,6 +155,11 @@ class UserEndpoint(BaseViewSet): workspaces_to_deactivate, ["is_active"], batch_size=100 ) + # Delete all workspace invites + WorkspaceMemberInvite.objects.filter( + email=user.email, + ).delete() + # Deactivate the user user.is_active = False @@ -166,10 +176,36 @@ class UserEndpoint(BaseViewSet): "workspace_invite": False, } profile.save() + + # User log out + 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/") @@ -239,6 +275,7 @@ class ProfileEndpoint(BaseAPIView): 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( diff --git a/apiserver/plane/app/views/view/base.py b/apiserver/plane/app/views/view/base.py index 35772ccf3..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,12 +36,12 @@ from plane.db.models import ( Workspace, IssueView, Issue, - IssueViewFavorite, + UserFavorite, IssueLink, IssueAttachment, ) from plane.utils.issue_filters import issue_filters - +from plane.utils.user_timezone_converter import user_timezone_converter class GlobalViewViewSet(BaseViewSet): serializer_class = IssueViewSerializer @@ -255,6 +254,10 @@ class GlobalViewIssuesViewSet(BaseViewSet): "is_draft", "archived_at", ) + datetime_fields = ["created_at", "updated_at"] + issues = user_timezone_converter( + issues, datetime_fields, request.user.user_timezone + ) return Response(issues, status=status.HTTP_200_OK) @@ -269,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"), ) @@ -306,8 +310,7 @@ class IssueViewViewSet(BaseViewSet): class IssueViewFavoriteViewSet(BaseViewSet): - serializer_class = IssueViewFavoriteSerializer - model = IssueViewFavorite + model = UserFavorite def get_queryset(self): return self.filter_queryset( @@ -319,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/app/views/workspace/cycle.py b/apiserver/plane/app/views/workspace/cycle.py index e85fa1cef..fa2954d67 100644 --- a/apiserver/plane/app/views/workspace/cycle.py +++ b/apiserver/plane/app/views/workspace/cycle.py @@ -27,7 +27,7 @@ class WorkspaceCyclesEndpoint(BaseAPIView): .select_related("project") .select_related("workspace") .select_related("owned_by") - .filter(archived_at__isnull=False) + .filter(archived_at__isnull=True) .annotate( total_issues=Count( "issue_cycle", diff --git a/apiserver/plane/app/views/workspace/module.py b/apiserver/plane/app/views/workspace/module.py index 085787694..7671692ec 100644 --- a/apiserver/plane/app/views/workspace/module.py +++ b/apiserver/plane/app/views/workspace/module.py @@ -30,7 +30,7 @@ class WorkspaceModulesEndpoint(BaseAPIView): .select_related("workspace") .select_related("lead") .prefetch_related("members") - .filter(archived_at__isnull=False) + .filter(archived_at__isnull=True) .prefetch_related( Prefetch( "link_module", diff --git a/apiserver/plane/authentication/adapter/base.py b/apiserver/plane/authentication/adapter/base.py index 9c5c83a4a..7b899e63c 100644 --- a/apiserver/plane/authentication/adapter/base.py +++ b/apiserver/plane/authentication/adapter/base.py @@ -3,7 +3,6 @@ import os import uuid # Django imports -from django.core.exceptions import ImproperlyConfigured from django.utils import timezone # Third party imports @@ -16,24 +15,16 @@ from plane.db.models import ( WorkspaceMemberInvite, ) from plane.license.utils.instance_value import get_configuration_value - - -class AuthenticationException(Exception): - - error_code = None - error_message = None - - def __init__(self, error_code, error_message): - self.error_code = error_code - self.error_message = error_message +from .error import AuthenticationException, AUTHENTICATION_ERROR_CODES class Adapter: """Common interface for all auth providers""" - def __init__(self, request, provider): + def __init__(self, request, provider, callback=None): self.request = request self.provider = provider + self.callback = callback self.token_data = None self.user_data = None @@ -58,7 +49,8 @@ class Adapter: 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( @@ -75,8 +67,10 @@ class Adapter: email=email, ).exists() ): - raise ImproperlyConfigured( - "Account creation is disabled for this instance please contact your admin" + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["SIGNUP_DISABLED"], + error_message="SIGNUP_DISABLED", + payload={"email": email}, ) user = User(email=email, username=uuid.uuid4().hex) @@ -89,8 +83,11 @@ class Adapter: results = zxcvbn(self.code) if results["score"] < 3: raise AuthenticationException( - error_message="The password is not a valid password", - error_code="INVALID_PASSWORD", + error_code=AUTHENTICATION_ERROR_CODES[ + "INVALID_PASSWORD" + ], + error_message="INVALID_PASSWORD", + payload={"email": email}, ) user.set_password(self.code) @@ -105,6 +102,12 @@ class Adapter: 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() @@ -114,6 +117,13 @@ class Adapter: 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) diff --git a/apiserver/plane/authentication/adapter/credential.py b/apiserver/plane/authentication/adapter/credential.py index b1fd75d02..0327289ca 100644 --- a/apiserver/plane/authentication/adapter/credential.py +++ b/apiserver/plane/authentication/adapter/credential.py @@ -4,8 +4,8 @@ from plane.authentication.adapter.base import Adapter class CredentialAdapter(Adapter): """Common interface for all credential providers""" - def __init__(self, request, provider): - super().__init__(request, provider) + def __init__(self, request, provider, callback=None): + super().__init__(request=request, provider=provider, callback=callback) self.request = request self.provider = provider diff --git a/apiserver/plane/authentication/adapter/error.py b/apiserver/plane/authentication/adapter/error.py new file mode 100644 index 000000000..47dbc8e8a --- /dev/null +++ b/apiserver/plane/authentication/adapter/error.py @@ -0,0 +1,80 @@ +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, +} + + +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..12845ea02 --- /dev/null +++ b/apiserver/plane/authentication/adapter/exception.py @@ -0,0 +1,12 @@ +from rest_framework.views import exception_handler +from rest_framework.exceptions import NotAuthenticated + + +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 + + return response diff --git a/apiserver/plane/authentication/adapter/oauth.py b/apiserver/plane/authentication/adapter/oauth.py index 91cab7c5f..b841db99d 100644 --- a/apiserver/plane/authentication/adapter/oauth.py +++ b/apiserver/plane/authentication/adapter/oauth.py @@ -23,8 +23,9 @@ class OauthAdapter(Adapter): userinfo_url, client_secret=None, code=None, + callback=None, ): - super().__init__(request, provider) + super().__init__(request=request, provider=provider, callback=callback) self.client_id = client_id self.scope = scope self.redirect_uri = redirect_uri diff --git a/apiserver/plane/authentication/middleware/session.py b/apiserver/plane/authentication/middleware/session.py index 697881e35..2bb62b881 100644 --- a/apiserver/plane/authentication/middleware/session.py +++ b/apiserver/plane/authentication/middleware/session.py @@ -38,11 +38,13 @@ class SessionMiddleware(MiddlewareMixin): 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 "instances" in request.path + if is_admin_path else settings.SESSION_COOKIE_NAME ) + if cookie_name in request.COOKIES and empty: response.delete_cookie( cookie_name, @@ -59,11 +61,16 @@ class SessionMiddleware(MiddlewareMixin): max_age = None expires = None else: - max_age = request.session.get_expiry_age() + # 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. - # Skip session save for 5xx responses. if response.status_code < 500: try: request.session.save() diff --git a/apiserver/plane/authentication/provider/credentials/email.py b/apiserver/plane/authentication/provider/credentials/email.py index 77c86da30..7e4e619d8 100644 --- a/apiserver/plane/authentication/provider/credentials/email.py +++ b/apiserver/plane/authentication/provider/credentials/email.py @@ -1,7 +1,14 @@ +# Python imports +import os + # Module imports -from plane.authentication.adapter.base import AuthenticationException 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): @@ -14,19 +21,39 @@ class EmailProvider(CredentialAdapter): key=None, code=None, is_signup=False, + callback=None, ): - super().__init__(request, self.provider) + 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 with this email already exists", - error_code="USER_ALREADY_EXIST", + error_message="USER_ALREADY_EXIST", + error_code=AUTHENTICATION_ERROR_CODES[ + "USER_ALREADY_EXIST" + ], ) super().set_user_data( @@ -46,18 +73,35 @@ class EmailProvider(CredentialAdapter): user = User.objects.filter( email=self.key, ).first() - # Existing user + + # User does not exists if not user: raise AuthenticationException( - error_message="Sorry, we could not find a user with the provided credentials. Please try again.", - error_code="AUTHENTICATION_FAILED", + 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="Sorry, we could not find a user with the provided credentials. Please try again.", - error_code="AUTHENTICATION_FAILED", + 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( diff --git a/apiserver/plane/authentication/provider/credentials/magic_code.py b/apiserver/plane/authentication/provider/credentials/magic_code.py index d49f19429..21309ea9c 100644 --- a/apiserver/plane/authentication/provider/credentials/magic_code.py +++ b/apiserver/plane/authentication/provider/credentials/magic_code.py @@ -4,14 +4,16 @@ import os import random import string -# Django imports -from django.core.exceptions import ImproperlyConfigured # Module imports -from plane.authentication.adapter.base import AuthenticationException 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): @@ -23,33 +25,44 @@ class MagicCodeProvider(CredentialAdapter): request, key, code=None, + callback=None, ): - (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"), - }, - ] - ) + ( + 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 and EMAIL_HOST_USER and EMAIL_HOST_PASSWORD): - raise ImproperlyConfigured( - "SMTP is not configured. Please contact the support team." + if not (EMAIL_HOST): + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["SMTP_NOT_CONFIGURED"], + error_message="SMTP_NOT_CONFIGURED", + payload={"email": str(self.key)}, ) - super().__init__(request, self.provider) + 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 @@ -74,7 +87,23 @@ class MagicCodeProvider(CredentialAdapter): current_attempt = data["current_attempt"] + 1 if data["current_attempt"] > 2: - return key, "" + 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, @@ -110,14 +139,42 @@ class MagicCodeProvider(CredentialAdapter): }, } ) + # Delete the token from redis if the code match is successful + ri.delete(self.key) return else: - raise AuthenticationException( - error_message="The token is not valid.", - error_code="INVALID_TOKEN", - ) + 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: - raise AuthenticationException( - error_message="The token has expired. Please regenerate the token and try again.", - error_code="EXPIRED_TOKEN", - ) + 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/github.py b/apiserver/plane/authentication/provider/oauth/github.py index ad8d913a1..edccea449 100644 --- a/apiserver/plane/authentication/provider/oauth/github.py +++ b/apiserver/plane/authentication/provider/oauth/github.py @@ -6,12 +6,13 @@ from urllib.parse import urlencode import pytz import requests -# Django imports -from django.core.exceptions import ImproperlyConfigured - # 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): @@ -21,7 +22,7 @@ class GitHubOAuthProvider(OauthAdapter): provider = "github" scope = "read:user user:email" - def __init__(self, request, code=None, state=None): + def __init__(self, request, code=None, state=None, callback=None): GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET = get_configuration_value( [ @@ -37,16 +38,15 @@ class GitHubOAuthProvider(OauthAdapter): ) if not (GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET): - raise ImproperlyConfigured( - "Google is not configured. Please contact the support team." + 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"{request.scheme}://{request.get_host()}/auth/github/callback/" - ) + 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, @@ -67,6 +67,7 @@ class GitHubOAuthProvider(OauthAdapter): self.userinfo_url, client_secret, code, + callback=callback, ) def set_token_data(self): diff --git a/apiserver/plane/authentication/provider/oauth/google.py b/apiserver/plane/authentication/provider/oauth/google.py index 94a827c9d..591295cb1 100644 --- a/apiserver/plane/authentication/provider/oauth/google.py +++ b/apiserver/plane/authentication/provider/oauth/google.py @@ -5,12 +5,13 @@ from urllib.parse import urlencode import pytz -# Django imports -from django.core.exceptions import ImproperlyConfigured - # 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): @@ -19,7 +20,7 @@ class GoogleOAuthProvider(OauthAdapter): 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): + def __init__(self, request, code=None, state=None, callback=None): (GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET) = get_configuration_value( [ { @@ -34,16 +35,15 @@ class GoogleOAuthProvider(OauthAdapter): ) if not (GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET): - raise ImproperlyConfigured( - "Google is not configured. Please contact the support team." + 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"{request.scheme}://{request.get_host()}/auth/google/callback/" - ) + 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, @@ -66,6 +66,7 @@ class GoogleOAuthProvider(OauthAdapter): self.userinfo_url, client_secret, code, + callback=callback, ) def set_token_data(self): diff --git a/apiserver/plane/authentication/urls.py b/apiserver/plane/authentication/urls.py index 451b17e4e..4a6f8c3f4 100644 --- a/apiserver/plane/authentication/urls.py +++ b/apiserver/plane/authentication/urls.py @@ -7,6 +7,7 @@ from .views import ( ForgotPasswordEndpoint, SetUserPasswordEndpoint, ResetPasswordEndpoint, + ChangePasswordEndpoint, # App GitHubCallbackEndpoint, GitHubOauthInitiateEndpoint, @@ -18,6 +19,8 @@ from .views import ( SignInAuthEndpoint, SignOutAuthEndpoint, SignUpAuthEndpoint, + ForgotPasswordSpaceEndpoint, + ResetPasswordSpaceEndpoint, # Space EmailCheckEndpoint, GitHubCallbackSpaceEndpoint, @@ -176,6 +179,21 @@ urlpatterns = [ 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(), diff --git a/apiserver/plane/authentication/utils/host.py b/apiserver/plane/authentication/utils/host.py index 80f492d53..4046c1e20 100644 --- a/apiserver/plane/authentication/utils/host.py +++ b/apiserver/plane/authentication/utils/host.py @@ -1,10 +1,42 @@ +# Python imports from urllib.parse import urlsplit +# Django imports +from django.conf import settings -def base_host(request): + +def base_host(request, is_admin=False, is_space=False, is_app=False): """Utility function to return host / origin from the request""" - return ( + # 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"{request.scheme}://{request.get_host()}" + 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 index 7dc2eb1ca..f5d453d02 100644 --- a/apiserver/plane/authentication/utils/login.py +++ b/apiserver/plane/authentication/utils/login.py @@ -1,11 +1,27 @@ +# 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): +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() diff --git a/apiserver/plane/authentication/utils/redirection_path.py b/apiserver/plane/authentication/utils/redirection_path.py index bf9e15673..12de25cc2 100644 --- a/apiserver/plane/authentication/utils/redirection_path.py +++ b/apiserver/plane/authentication/utils/redirection_path.py @@ -10,10 +10,13 @@ def get_redirection_path(user): 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, + 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, 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/views/__init__.py b/apiserver/plane/authentication/views/__init__.py index 4bd920e29..a5aadf728 100644 --- a/apiserver/plane/authentication/views/__init__.py +++ b/apiserver/plane/authentication/views/__init__.py @@ -1,8 +1,6 @@ from .common import ( ChangePasswordEndpoint, CSRFTokenEndpoint, - ForgotPasswordEndpoint, - ResetPasswordEndpoint, SetUserPasswordEndpoint, ) @@ -50,3 +48,12 @@ from .space.magic import ( from .space.signout import SignOutAuthSpaceEndpoint from .space.check import EmailCheckEndpoint + +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 index 54b39ed6f..4f164e308 100644 --- a/apiserver/plane/authentication/views/app/check.py +++ b/apiserver/plane/authentication/views/app/check.py @@ -1,3 +1,7 @@ +# 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 @@ -7,6 +11,10 @@ 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, +) class EmailCheckSignUpEndpoint(APIView): @@ -16,32 +24,60 @@ class EmailCheckSignUpEndpoint(APIView): ] def post(self, request): - # Check instance configuration - instance = Instance.objects.first() - if instance is None or not instance.is_setup_done: - return Response( - { - "error_code": "INSTANCE_NOT_CONFIGURED", - "error_message": "Instance is not configured", - }, - status=status.HTTP_400_BAD_REQUEST, - ) + try: + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INSTANCE_NOT_CONFIGURED" + ], + error_message="INSTANCE_NOT_CONFIGURED", + ) + email = request.data.get("email", False) - email = request.data.get("email", False) - existing_user = User.objects.filter(email=email).first() + # Return error if email is not present + if not email: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["EMAIL_REQUIRED"], + error_message="EMAIL_REQUIRED", + ) - if existing_user: + # Validate email + validate_email(email) + + existing_user = User.objects.filter(email=email).first() + + if existing_user: + # check if the account is the deactivated + if not existing_user.is_active: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "USER_ACCOUNT_DEACTIVATED" + ], + error_message="USER_ACCOUNT_DEACTIVATED", + ) + + # Raise user already exist + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "USER_ALREADY_EXIST" + ], + error_message="USER_ALREADY_EXIST", + ) return Response( - { - "error_code": "USER_ALREADY_EXIST", - "error_message": "User already exists with the email.", - }, - status=status.HTTP_400_BAD_REQUEST, + {"status": True}, + status=status.HTTP_200_OK, + ) + except ValidationError: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"], + error_message="INVALID_EMAIL", + ) + except AuthenticationException as e: + return Response( + e.get_error_dict(), status=status.HTTP_400_BAD_REQUEST ) - return Response( - {"status": True}, - status=status.HTTP_200_OK, - ) class EmailCheckSignInEndpoint(APIView): @@ -51,32 +87,61 @@ class EmailCheckSignInEndpoint(APIView): ] def post(self, request): - # Check instance configuration - instance = Instance.objects.first() - if instance is None or not instance.is_setup_done: - return Response( - { - "error_code": "INSTANCE_NOT_CONFIGURED", - "error_message": "Instance is not configured", - }, - status=status.HTTP_400_BAD_REQUEST, - ) + try: + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INSTANCE_NOT_CONFIGURED" + ], + error_message="INSTANCE_NOT_CONFIGURED", + ) - email = request.data.get("email", False) - existing_user = User.objects.filter(email=email).first() + email = request.data.get("email", False) - if existing_user: - return Response( - { - "status": True, - "is_password_autoset": existing_user.is_password_autoset, - }, - status=status.HTTP_200_OK, + # Return error if email is not present + if not email: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["EMAIL_REQUIRED"], + error_message="EMAIL_REQUIRED", + ) + + # Validate email + validate_email(email) + + existing_user = User.objects.filter(email=email).first() + + # If existing user + if existing_user: + # Raise different exception when user is not active + if not existing_user.is_active: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "USER_ACCOUNT_DEACTIVATED" + ], + error_message="USER_ACCOUNT_DEACTIVATED", + ) + # Return true + return Response( + { + "status": True, + "is_password_autoset": existing_user.is_password_autoset, + }, + status=status.HTTP_200_OK, + ) + + # Raise error + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"], + error_message="USER_DOES_NOT_EXIST", + ) + except ValidationError: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"], + error_message="INVALID_EMAIL", + ) + except AuthenticationException as e: + return Response( + e.get_error_dict(), status=status.HTTP_400_BAD_REQUEST ) - return Response( - { - "error_code": "USER_DOES_NOT_EXIST", - "error_message": "User could not be found with the given email.", - }, - status=status.HTTP_400_BAD_REQUEST, - ) diff --git a/apiserver/plane/authentication/views/app/email.py b/apiserver/plane/authentication/views/app/email.py index 894af3cbb..f21e431a4 100644 --- a/apiserver/plane/authentication/views/app/email.py +++ b/apiserver/plane/authentication/views/app/email.py @@ -8,16 +8,19 @@ from django.http import HttpResponseRedirect from django.views import View # Module imports -from plane.authentication.adapter.base import AuthenticationException 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.workspace_project_join import ( - process_workspace_project_invitations, +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): @@ -28,16 +31,19 @@ class SignInAuthEndpoint(View): instance = Instance.objects.first() if instance is None or not instance.is_setup_done: # Redirection params - params = { - "error_code": "REQUIRED_EMAIL_PASSWORD", - "error_message": "Both email and password are required", - } + 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), - "accounts/sign-in?" + urlencode(params), + base_host(request=request, is_app=True), + "sign-in?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -48,16 +54,20 @@ class SignInAuthEndpoint(View): ## Raise exception if any of the above are missing if not email or not password: # Redirection params - params = { - "error_code": "REQUIRED_EMAIL_PASSWORD", - "error_message": "Both email and password are required", - } + 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), - "accounts/sign-in?" + urlencode(params), + base_host(request=request, is_app=True), + "sign-in?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -66,40 +76,64 @@ class SignInAuthEndpoint(View): try: validate_email(email) except ValidationError: - params = { - "error_code": "INVALID_EMAIL", - "error_message": "Please provide a valid email address.", - } + 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), - "accounts/sign-in?" + urlencode(params), + base_host(request=request, is_app=True), + "sign-in?" + urlencode(params), ) return HttpResponseRedirect(url) - if not User.objects.filter(email=email).exists(): - params = { - "error_code": "USER_DOES_NOT_EXIST", - "error_message": "User could not be found with the given email.", - } + 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), - "accounts/sign-in?" + urlencode(params), + 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 + 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) - # Process workspace and project invitations - process_workspace_project_invitations(user=user) + user_login(request=request, user=user, is_app=True) # Get the redirection path if next_path: path = str(next_path) @@ -107,18 +141,15 @@ class SignInAuthEndpoint(View): path = get_redirection_path(user=user) # redirect to referer path - url = urljoin(base_host(request=request), path) + url = urljoin(base_host(request=request, is_app=True), path) return HttpResponseRedirect(url) except AuthenticationException as e: - params = { - "error_code": str(e.error_code), - "error_message": str(e.error_message), - } + params = e.get_error_dict() if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), - "accounts/sign-in?" + urlencode(params), + base_host(request=request, is_app=True), + "sign-in?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -130,14 +161,18 @@ class SignUpAuthEndpoint(View): # Check instance configuration instance = Instance.objects.first() if instance is None or not instance.is_setup_done: - params = { - "error_code": "INSTANCE_NOT_CONFIGURED", - "error_message": "Instance is not configured", - } + # 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), + base_host(request=request, is_app=True), "?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -146,14 +181,19 @@ class SignUpAuthEndpoint(View): password = request.POST.get("password", False) ## Raise exception if any of the above are missing if not email or not password: - params = { - "error_code": "REQUIRED_EMAIL_PASSWORD", - "error_message": "Both email and password are required", - } + # 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), + base_host(request=request, is_app=True), "?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -162,57 +202,81 @@ class SignUpAuthEndpoint(View): try: validate_email(email) except ValidationError: - params = { - "error_code": "INVALID_EMAIL", - "error_message": "Please provide a valid email address.", - } + # 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), + base_host(request=request, is_app=True), "?" + urlencode(params), ) return HttpResponseRedirect(url) - if User.objects.filter(email=email).exists(): - params = { - "error_code": "USER_ALREADY_EXIST", - "error_message": "User already exists with the email.", - } + # 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), + base_host(request=request, is_app=True), "?" + urlencode(params), ) return HttpResponseRedirect(url) try: provider = EmailProvider( - request=request, key=email, code=password, is_signup=True + 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) - # Process workspace and project invitations - process_workspace_project_invitations(user=user) + 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), path) + url = urljoin(base_host(request=request, is_app=True), path) return HttpResponseRedirect(url) except AuthenticationException as e: - params = { - "error_code": str(e.error_code), - "error_message": str(e.error_message), - } + params = e.get_error_dict() if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + 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 index 4d299ef4f..f93beefa3 100644 --- a/apiserver/plane/authentication/views/app/github.py +++ b/apiserver/plane/authentication/views/app/github.py @@ -2,7 +2,6 @@ import uuid from urllib.parse import urlencode, urljoin # Django import -from django.core.exceptions import ImproperlyConfigured from django.http import HttpResponseRedirect from django.views import View @@ -10,18 +9,22 @@ from django.views import View 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.workspace_project_join import ( - process_workspace_project_invitations, +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) + 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) @@ -29,12 +32,17 @@ class GitHubOauthInitiateEndpoint(View): # Check instance configuration instance = Instance.objects.first() if instance is None or not instance.is_setup_done: - params = { - "error_code": "INSTANCE_NOT_CONFIGURED", - "error_message": "Instance is not configured", - } + 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), + base_host(request=request, is_app=True), "?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -44,15 +52,12 @@ class GitHubOauthInitiateEndpoint(View): request.session["state"] = state auth_url = provider.get_auth_url() return HttpResponseRedirect(auth_url) - except ImproperlyConfigured as e: - params = { - "error_code": "IMPROPERLY_CONFIGURED", - "error_message": str(e), - } + except AuthenticationException as e: + params = e.get_error_dict() if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + base_host(request=request, is_app=True), "?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -67,10 +72,13 @@ class GitHubCallbackEndpoint(View): next_path = request.session.get("next_path") if state != request.session.get("state", ""): - params = { - "error_code": "OAUTH_PROVIDER_ERROR", - "error_message": "State does not match", - } + 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( @@ -80,10 +88,13 @@ class GitHubCallbackEndpoint(View): return HttpResponseRedirect(url) if not code: - params = { - "error_code": "OAUTH_PROVIDER_ERROR", - "error_message": "Something went wrong while fetching data from OAuth provider. Please try again after sometime.", - } + 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( @@ -96,12 +107,11 @@ class GitHubCallbackEndpoint(View): 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) - # Process workspace and project invitations - process_workspace_project_invitations(user=user) + user_login(request=request, user=user, is_app=True) # Get the redirection path if next_path: path = next_path @@ -110,11 +120,8 @@ class GitHubCallbackEndpoint(View): # redirect to referer path url = urljoin(base_host, path) return HttpResponseRedirect(url) - except ImproperlyConfigured as e: - params = { - "error_code": "IMPROPERLY_CONFIGURED", - "error_message": str(e), - } + except AuthenticationException as e: + params = e.get_error_dict() if next_path: params["next_path"] = str(next_path) url = urljoin( diff --git a/apiserver/plane/authentication/views/app/google.py b/apiserver/plane/authentication/views/app/google.py index bbadc0066..05f4511e2 100644 --- a/apiserver/plane/authentication/views/app/google.py +++ b/apiserver/plane/authentication/views/app/google.py @@ -3,25 +3,28 @@ import uuid from urllib.parse import urlencode, urljoin # Django import -from django.core.exceptions import ImproperlyConfigured 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.workspace_project_join import ( - process_workspace_project_invitations, +from plane.authentication.utils.user_auth_workflow import ( + post_user_auth_workflow, ) - -# Module imports 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) + 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) @@ -29,14 +32,17 @@ class GoogleOauthInitiateEndpoint(View): # Check instance configuration instance = Instance.objects.first() if instance is None or not instance.is_setup_done: - params = { - "error_code": "INSTANCE_NOT_CONFIGURED", - "error_message": "Instance is not configured", - } + 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), + base_host(request=request, is_app=True), "?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -47,15 +53,12 @@ class GoogleOauthInitiateEndpoint(View): request.session["state"] = state auth_url = provider.get_auth_url() return HttpResponseRedirect(auth_url) - except ImproperlyConfigured as e: - params = { - "error_code": "IMPROPERLY_CONFIGURED", - "error_message": str(e), - } + except AuthenticationException as e: + params = e.get_error_dict() if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + base_host(request=request, is_app=True), "?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -69,10 +72,13 @@ class GoogleCallbackEndpoint(View): next_path = request.session.get("next_path") if state != request.session.get("state", ""): - params = { - "error_code": "OAUTH_PROVIDER_ERROR", - "error_message": "State does not match", - } + 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( @@ -81,10 +87,13 @@ class GoogleCallbackEndpoint(View): ) return HttpResponseRedirect(url) if not code: - params = { - "error_code": "OAUTH_PROVIDER_ERROR", - "error_message": "Something went wrong while fetching data from OAuth provider. Please try again after sometime.", - } + 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( @@ -96,22 +105,18 @@ class GoogleCallbackEndpoint(View): 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) - # Process workspace and project invitations - process_workspace_project_invitations(user=user) + 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 ImproperlyConfigured as e: - params = { - "error_code": "IMPROPERLY_CONFIGURED", - "error_message": str(e), - } + except AuthenticationException as e: + params = e.get_error_dict() if next_path: params["next_path"] = str(next_path) url = urljoin( diff --git a/apiserver/plane/authentication/views/app/magic.py b/apiserver/plane/authentication/views/app/magic.py index da14acbef..bb3c72534 100644 --- a/apiserver/plane/authentication/views/app/magic.py +++ b/apiserver/plane/authentication/views/app/magic.py @@ -2,7 +2,6 @@ from urllib.parse import urlencode, urljoin # Django imports -from django.core.exceptions import ImproperlyConfigured, ValidationError from django.core.validators import validate_email from django.http import HttpResponseRedirect from django.views import View @@ -14,19 +13,22 @@ from rest_framework.response import Response from rest_framework.views import APIView # Module imports -from plane.authentication.adapter.base import AuthenticationException 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.workspace_project_join import ( - process_workspace_project_invitations, +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 +from plane.db.models import User, Profile +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) class MagicGenerateEndpoint(APIView): @@ -39,11 +41,14 @@ class MagicGenerateEndpoint(APIView): # 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( - { - "error_code": "INSTANCE_NOT_CONFIGURED", - "error_message": "Instance is not configured", - } + exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST ) origin = request.META.get("HTTP_ORIGIN", "/") @@ -57,28 +62,10 @@ class MagicGenerateEndpoint(APIView): # 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 ImproperlyConfigured as e: - return Response( - { - "error_code": "IMPROPERLY_CONFIGURED", - "error_message": str(e), - }, - status=status.HTTP_400_BAD_REQUEST, - ) except AuthenticationException as e: + params = e.get_error_dict() return Response( - { - "error_code": str(e.error_code), - "error_message": str(e.error_message), - }, - status=status.HTTP_400_BAD_REQUEST, - ) - except ValidationError: - return Response( - { - "error_code": "INVALID_EMAIL", - "error_message": "Valid email is required for generating a magic code", - }, + params, status=status.HTTP_400_BAD_REQUEST, ) @@ -93,63 +80,85 @@ class MagicSignInEndpoint(View): next_path = request.POST.get("next_path") if code == "" or email == "": - params = { - "error_code": "EMAIL_CODE_REQUIRED", - "error_message": "Email and code are required", - } + 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), - "accounts/sign-in?" + urlencode(params), + base_host(request=request, is_app=True), + "sign-in?" + urlencode(params), ) return HttpResponseRedirect(url) - if not User.objects.filter(email=email).exists(): - params = { - "error_code": "USER_DOES_NOT_EXIST", - "error_message": "User could not be found with the given email.", - } + # 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), - "accounts/sign-in?" + urlencode(params), + 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 + 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) - # Process workspace and project invitations - process_workspace_project_invitations(user=user) - if user.is_password_autoset: + 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(process_workspace_project_invitations(user=user)) + else str(get_redirection_path(user=user)) ) # redirect to referer path - url = urljoin(base_host(request=request), path) + url = urljoin(base_host(request=request, is_app=True), path) return HttpResponseRedirect(url) except AuthenticationException as e: - params = { - "error_code": str(e.error_code), - "error_message": str(e.error_message), - } + params = e.get_error_dict() if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), - "accounts/sign-in?" + urlencode(params), + base_host(request=request, is_app=True), + "sign-in?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -164,58 +173,61 @@ class MagicSignUpEndpoint(View): next_path = request.POST.get("next_path") if code == "" or email == "": - params = { - "error_code": "EMAIL_CODE_REQUIRED", - "error_message": "Email and code are required", - } + 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), + base_host(request=request, is_app=True), "?" + urlencode(params), ) return HttpResponseRedirect(url) - - if User.objects.filter(email=email).exists(): - params = { - "error_code": "USER_ALREADY_EXIST", - "error_message": "User already exists with the email.", - } + # 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), + base_host(request=request, is_app=True), "?" + urlencode(params), ) return HttpResponseRedirect(url) try: provider = MagicCodeProvider( - request=request, key=f"magic_{email}", code=code + 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) - # Process workspace and project invitations - process_workspace_project_invitations(user=user) + 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), path) + url = urljoin(base_host(request=request, is_app=True), path) return HttpResponseRedirect(url) except AuthenticationException as e: - params = { - "error_code": str(e.error_code), - "error_message": str(e.error_message), - } + params = e.get_error_dict() if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + 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..dd14ceb91 --- /dev/null +++ b/apiserver/plane/authentication/views/app/password_management.py @@ -0,0 +1,193 @@ +# 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, +) + + +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, + ] + + 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 index 46cd0fa7c..10461f240 100644 --- a/apiserver/plane/authentication/views/app/signout.py +++ b/apiserver/plane/authentication/views/app/signout.py @@ -1,21 +1,31 @@ # Python imports -from urllib.parse import urlencode, urljoin +from urllib.parse import urljoin # 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 +from plane.authentication.utils.host import user_ip, base_host +from plane.db.models import User class SignOutAuthEndpoint(View): def post(self, request): - logout(request) - url = urljoin( - base_host(request=request), - "accounts/sign-in?" + urlencode({"success": "true"}), - ) - return HttpResponseRedirect(url) + # 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_app=True), "sign-in") + return HttpResponseRedirect(url) + except Exception: + return HttpResponseRedirect( + base_host(request=request, is_app=True), "sign-in" + ) diff --git a/apiserver/plane/authentication/views/common.py b/apiserver/plane/authentication/views/common.py index 693054596..640f744ce 100644 --- a/apiserver/plane/authentication/views/common.py +++ b/apiserver/plane/authentication/views/common.py @@ -1,21 +1,3 @@ -# Python imports -import os -from urllib.parse import urlencode, urljoin - -# 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.middleware.csrf import get_token -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 - # Third party imports from rest_framework import status from rest_framework.permissions import AllowAny @@ -25,15 +7,16 @@ from zxcvbn import zxcvbn ## Module imports from plane.app.serializers import ( - ChangePasswordSerializer, UserSerializer, ) from plane.authentication.utils.login import user_login -from plane.bgtasks.forgot_password_task import forgot_password from plane.db.models import User -from plane.license.models import Instance -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 django.middleware.csrf import get_token +from plane.utils.cache import invalidate_cache class CSRFTokenEndpoint(APIView): @@ -51,235 +34,100 @@ class CSRFTokenEndpoint(APIView): ) -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, - ] - - 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: - return Response( - { - "error_code": "INSTANCE_NOT_CONFIGURED", - "error_message": "Instance is not configured", - }, - 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 and EMAIL_HOST_USER and EMAIL_HOST_PASSWORD): - return Response( - { - "error_code": "SMTP_NOT_CONFIGURED", - "error_message": "SMTP is not configured. Please contact your admin", - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - try: - validate_email(email) - except ValidationError: - return Response( - { - "error_code": "INVALID_EMAIL", - "error_message": "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_code": "INVALID_EMAIL", - "error_message": "Please check the email", - }, - 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): - url = urljoin( - base_host(request=request), - "accounts/reset-password?" - + urlencode( - { - "error_code": "INVALID_TOKEN", - "error_message": "Token is invalid", - } - ), - ) - return HttpResponseRedirect(url) - - password = request.POST.get("password", False) - - if not password: - url = urljoin( - base_host(request=request), - "?" + urlencode({"error": "Password is required"}), - ) - return HttpResponseRedirect(url) - - # Check the password complexity - results = zxcvbn(password) - if results["score"] < 3: - url = urljoin( - base_host(request=request), - "accounts/reset-password?" - + urlencode( - { - "error_code": "INVALID_PASSWORD", - "error_message": "The password is not a valid password", - } - ), - ) - 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), - "accounts/sign-in?" + urlencode({"success", True}), - ) - return HttpResponseRedirect(url) - except DjangoUnicodeDecodeError: - url = urljoin( - base_host(request=request), - "accounts/reset-password?" - + urlencode( - { - "error_code": "INVALID_TOKEN", - "error_message": "The password token is not valid", - } - ), - ) - return HttpResponseRedirect(url) - - class ChangePasswordEndpoint(APIView): 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, - ) - # check the password score - results = zxcvbn(serializer.data.get("new_password")) - if results["score"] < 3: - return Response( - { - "error_code": "INVALID_PASSWORD", - "error_message": "Invalid password.", - }, - status=status.HTTP_400_BAD_REQUEST, - ) + old_password = request.data.get("old_password", False) + new_password = request.data.get("new_password", False) - # 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() - user_login(user=user, request=request) - return Response( - {"message": "Password updated successfully"}, - status=status.HTTP_200_OK, + 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( - { - "error_code": "INVALID_PASSWORD", - "error_message": "Invalid passwords provided", - }, - status=status.HTTP_400_BAD_REQUEST, + {"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: - return Response( - { + 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 and len(str(password)) < 8: + if not password: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"], + error_message="INVALID_PASSWORD", + ) return Response( - { - "error_code": "INVALID_PASSWORD", - "error_message": "Invalid password.", - }, + 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( - { - "error_code": "INVALID_PASSWORD", - "error_message": "Invalid password.", - }, + exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST, ) @@ -288,7 +136,7 @@ class SetUserPasswordEndpoint(APIView): user.is_password_autoset = False user.save() # Login the user as the session is invalidated - user_login(user=user, request=request) + 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 index 9f16cc45f..83f52e28f 100644 --- a/apiserver/plane/authentication/views/space/check.py +++ b/apiserver/plane/authentication/views/space/check.py @@ -1,3 +1,7 @@ +# 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 @@ -7,6 +11,10 @@ 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, +) class EmailCheckEndpoint(APIView): @@ -19,21 +27,58 @@ class EmailCheckEndpoint(APIView): # 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( - { - "error_code": "INSTANCE_NOT_CONFIGURED", - "error_message": "Instance is not configured", - }, + exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST, ) 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, diff --git a/apiserver/plane/authentication/views/space/email.py b/apiserver/plane/authentication/views/space/email.py index 8849fab7b..7a5613a75 100644 --- a/apiserver/plane/authentication/views/space/email.py +++ b/apiserver/plane/authentication/views/space/email.py @@ -1,5 +1,5 @@ # Python imports -from urllib.parse import urlencode, urljoin +from urllib.parse import urlencode # Django imports from django.core.exceptions import ValidationError @@ -8,12 +8,15 @@ from django.http import HttpResponseRedirect from django.views import View # Module imports -from plane.authentication.adapter.base import AuthenticationException 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): @@ -23,16 +26,17 @@ class SignInAuthSpaceEndpoint(View): # Check instance configuration instance = Instance.objects.first() if instance is None or not instance.is_setup_done: - params = { - "error_code": "INSTANCE_NOT_CONFIGURED", - "error_message": "Instance is not configured", - } + # 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), - "accounts/sign-in?" + urlencode(params), - ) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) # set the referer as session to redirect after login @@ -41,16 +45,17 @@ class SignInAuthSpaceEndpoint(View): ## Raise exception if any of the above are missing if not email or not password: - params = { - "error_code": "REQUIRED_EMAIL_PASSWORD", - "error_message": "Both email and password are required", - } + 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 = urljoin( - base_host(request=request), - "spaces/accounts/sign-in?" + urlencode(params), - ) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) # Validate email @@ -58,29 +63,43 @@ class SignInAuthSpaceEndpoint(View): try: validate_email(email) except ValidationError: - params = { - "error_code": "INVALID_EMAIL", - "error_message": "Please provide a valid email address.", - } + 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), - "spaces/accounts/sign-in?" + urlencode(params), - ) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) - if not User.objects.filter(email=email).exists(): - params = { - "error_code": "USER_DOES_NOT_EXIST", - "error_message": "User could not be found with the given email.", - } + # 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 = urljoin( - base_host(request=request), - "spaces/accounts/sign-in?" + urlencode(params), + 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: @@ -89,24 +108,15 @@ class SignInAuthSpaceEndpoint(View): ) user = provider.authenticate() # Login the user and record his device info - user_login(request=request, user=user) + user_login(request=request, user=user, is_space=True) # redirect to next path - url = urljoin( - base_host(request=request), - str(next_path) if next_path else "/", - ) + url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}" return HttpResponseRedirect(url) except AuthenticationException as e: - params = { - "error_code": str(e.error_code), - "error_message": str(e.error_message), - } + params = e.get_error_dict() if next_path: params["next_path"] = str(next_path) - url = urljoin( - base_host(request=request), - "spaces/accounts/sign-in?" + urlencode(params), - ) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) @@ -117,61 +127,79 @@ class SignUpAuthSpaceEndpoint(View): # Check instance configuration instance = Instance.objects.first() if instance is None or not instance.is_setup_done: - params = { - "error_code": "INSTANCE_NOT_CONFIGURED", - "error_message": "Instance is not configured", - } + # 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), - "spaces?" + urlencode(params), - ) + 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: - params = { - "error_code": "REQUIRED_EMAIL_PASSWORD", - "error_message": "Both email and password are required", - } + # 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), - "spaces?" + urlencode(params), - ) + 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: - params = { - "error_code": "INVALID_EMAIL", - "error_message": "Please provide a valid email address.", - } + # 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), - "spaces?" + urlencode(params), - ) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) - if User.objects.filter(email=email).exists(): - params = { - "error_code": "USER_ALREADY_EXIST", - "error_message": "User already exists with the email.", - } + # 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 = urljoin( - base_host(request=request), - "spaces?" + urlencode(params), - ) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) try: @@ -180,22 +208,13 @@ class SignUpAuthSpaceEndpoint(View): ) user = provider.authenticate() # Login the user and record his device info - user_login(request=request, user=user) + user_login(request=request, user=user, is_space=True) # redirect to referer path - url = urljoin( - base_host(request=request), - str(next_path) if next_path else "spaces", - ) + url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}" return HttpResponseRedirect(url) except AuthenticationException as e: - params = { - "error_code": str(e.error_code), - "error_message": str(e.error_message), - } + params = e.get_error_dict() if next_path: params["next_path"] = str(next_path) - url = urljoin( - base_host(request=request), - "spaces?" + urlencode(params), - ) + 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 index 082d1578f..711f7eaa7 100644 --- a/apiserver/plane/authentication/views/space/github.py +++ b/apiserver/plane/authentication/views/space/github.py @@ -1,9 +1,8 @@ # Python imports import uuid -from urllib.parse import urlencode, urljoin +from urllib.parse import urlencode # Django import -from django.core.exceptions import ImproperlyConfigured from django.http import HttpResponseRedirect from django.views import View @@ -12,13 +11,17 @@ 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) + 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) @@ -26,16 +29,16 @@ class GitHubOauthInitiateSpaceEndpoint(View): # Check instance configuration instance = Instance.objects.first() if instance is None or not instance.is_setup_done: - params = { - "error_code": "INSTANCE_NOT_CONFIGURED", - "error_message": "Instance is not configured", - } + 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), - "?" + urlencode(params), - ) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) try: @@ -44,17 +47,11 @@ class GitHubOauthInitiateSpaceEndpoint(View): request.session["state"] = state auth_url = provider.get_auth_url() return HttpResponseRedirect(auth_url) - except ImproperlyConfigured as e: - params = { - "error_code": "IMPROPERLY_CONFIGURED", - "error_message": str(e), - } + except AuthenticationException as e: + params = e.get_error_dict() if next_path: params["next_path"] = str(next_path) - url = urljoin( - base_host(request=request), - "?" + urlencode(params), - ) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) @@ -67,29 +64,29 @@ class GitHubCallbackSpaceEndpoint(View): next_path = request.session.get("next_path") if state != request.session.get("state", ""): - params = { - "error_code": "OAUTH_PROVIDER_ERROR", - "error_message": "State does not match", - } + 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), - ) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) if not code: - params = { - "error_code": "OAUTH_PROVIDER_ERROR", - "error_message": "Something went wrong while fetching data from OAuth provider. Please try again after sometime.", - } + 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), - ) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) try: @@ -99,20 +96,14 @@ class GitHubCallbackSpaceEndpoint(View): ) user = provider.authenticate() # Login the user and record his device info - user_login(request=request, user=user) + user_login(request=request, user=user, is_space=True) # Process workspace and project invitations # redirect to referer path - url = urljoin(base_host, str(next_path) if next_path else "/") + url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}" return HttpResponseRedirect(url) - except ImproperlyConfigured as e: - params = { - "error_code": "IMPROPERLY_CONFIGURED", - "error_message": str(e), - } + except AuthenticationException as e: + params = e.get_error_dict() if next_path: params["next_path"] = str(next_path) - url = urljoin( - base_host, - "?" + urlencode(params), - ) + 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 index 354d73078..38a2b910a 100644 --- a/apiserver/plane/authentication/views/space/google.py +++ b/apiserver/plane/authentication/views/space/google.py @@ -1,24 +1,25 @@ # Python imports import uuid -from urllib.parse import urlencode, urljoin +from urllib.parse import urlencode # Django import -from django.core.exceptions import ImproperlyConfigured 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 - - -# Module imports 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) + 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) @@ -26,16 +27,16 @@ class GoogleOauthInitiateSpaceEndpoint(View): # Check instance configuration instance = Instance.objects.first() if instance is None or not instance.is_setup_done: - params = { - "error_code": "INSTANCE_NOT_CONFIGURED", - "error_message": "Instance is not configured", - } + 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), - "?" + urlencode(params), - ) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) try: @@ -44,17 +45,11 @@ class GoogleOauthInitiateSpaceEndpoint(View): request.session["state"] = state auth_url = provider.get_auth_url() return HttpResponseRedirect(auth_url) - except ImproperlyConfigured as e: - params = { - "error_code": "IMPROPERLY_CONFIGURED", - "error_message": str(e), - } + except AuthenticationException as e: + params = e.get_error_dict() if next_path: params["next_path"] = str(next_path) - url = urljoin( - base_host(request=request), - "?" + urlencode(params), - ) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) @@ -66,28 +61,28 @@ class GoogleCallbackSpaceEndpoint(View): next_path = request.session.get("next_path") if state != request.session.get("state", ""): - params = { - "error_code": "OAUTH_PROVIDER_ERROR", - "error_message": "State does not match", - } + 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), - ) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) if not code: - params = { - "error_code": "OAUTH_PROVIDER_ERROR", - "error_message": "Something went wrong while fetching data from OAuth provider. Please try again after sometime.", - } + 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), - ) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) try: provider = GoogleOAuthProvider( @@ -96,21 +91,13 @@ class GoogleCallbackSpaceEndpoint(View): ) user = provider.authenticate() # Login the user and record his device info - user_login(request=request, user=user) + user_login(request=request, user=user, is_space=True) # redirect to referer path - url = urljoin( - base_host, str(next_path) if next_path else "/spaces" - ) + url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}" return HttpResponseRedirect(url) - except ImproperlyConfigured as e: - params = { - "error_code": "IMPROPERLY_CONFIGURED", - "error_message": str(e), - } + except AuthenticationException as e: + params = e.get_error_dict() if next_path: params["next_path"] = str(next_path) - url = urljoin( - base_host, - "?" + urlencode(params), - ) + 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 index bef7154cf..0e859d44d 100644 --- a/apiserver/plane/authentication/views/space/magic.py +++ b/apiserver/plane/authentication/views/space/magic.py @@ -1,8 +1,7 @@ # Python imports -from urllib.parse import urlencode, urljoin +from urllib.parse import urlencode # Django imports -from django.core.exceptions import ImproperlyConfigured, ValidationError from django.core.validators import validate_email from django.http import HttpResponseRedirect from django.views import View @@ -14,7 +13,6 @@ from rest_framework.response import Response from rest_framework.views import APIView # Module imports -from plane.authentication.adapter.base import AuthenticationException from plane.authentication.provider.credentials.magic_code import ( MagicCodeProvider, ) @@ -22,7 +20,11 @@ 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 +from plane.db.models import User, Profile +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) class MagicGenerateSpaceEndpoint(APIView): @@ -35,14 +37,17 @@ class MagicGenerateSpaceEndpoint(APIView): # 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( - { - "error_code": "INSTANCE_NOT_CONFIGURED", - "error_message": "Instance is not configured", - } + exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST ) - origin = base_host(request=request) + origin = base_host(request=request, is_space=True) email = request.data.get("email", False) try: # Clean up the email @@ -53,28 +58,9 @@ class MagicGenerateSpaceEndpoint(APIView): # 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 ImproperlyConfigured as e: - return Response( - { - "error_code": "IMPROPERLY_CONFIGURED", - "error_message": str(e), - }, - status=status.HTTP_400_BAD_REQUEST, - ) except AuthenticationException as e: return Response( - { - "error_code": str(e.error_code), - "error_message": str(e.error_message), - }, - status=status.HTTP_400_BAD_REQUEST, - ) - except ValidationError: - return Response( - { - "error_code": "INVALID_EMAIL", - "error_message": "Valid email is required for generating a magic code", - }, + e.get_error_dict(), status=status.HTTP_400_BAD_REQUEST, ) @@ -89,56 +75,66 @@ class MagicSignInSpaceEndpoint(View): next_path = request.POST.get("next_path") if code == "" or email == "": - params = { - "error_code": "EMAIL_CODE_REQUIRED", - "error_message": "Email and code are required", - } + 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), - "spaces/accounts/sign-in?" + urlencode(params), - ) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) - if not User.objects.filter(email=email).exists(): - params = { - "error_code": "USER_DOES_NOT_EXIST", - "error_message": "User could not be found with the given email.", - } + 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), - "accounts/sign-in?" + urlencode(params), - ) + 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) + user_login(request=request, user=user, is_space=True) # redirect to referer path - url = urljoin( - base_host(request=request), - str(next_path) if next_path else "spaces", - ) + 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 = { - "error_code": str(e.error_code), - "error_message": str(e.error_message), - } + params = e.get_error_dict() if next_path: params["next_path"] = str(next_path) - url = urljoin( - base_host(request=request), - "spaces/accounts/sign-in?" + urlencode(params), - ) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) @@ -152,29 +148,29 @@ class MagicSignUpSpaceEndpoint(View): next_path = request.POST.get("next_path") if code == "" or email == "": - params = { - "error_code": "EMAIL_CODE_REQUIRED", - "error_message": "Email and code are required", - } + 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), - "spaces/accounts/sign-in?" + urlencode(params), - ) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) - - if User.objects.filter(email=email).exists(): - params = { - "error_code": "USER_ALREADY_EXIST", - "error_message": "User already exists with the email.", - } + # 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 = urljoin( - base_host(request=request), - "?" + urlencode(params), - ) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) try: @@ -183,23 +179,14 @@ class MagicSignUpSpaceEndpoint(View): ) user = provider.authenticate() # Login the user and record his device info - user_login(request=request, user=user) + user_login(request=request, user=user, is_space=True) # redirect to referer path - url = urljoin( - base_host(request=request), - str(next_path) if next_path else "spaces", - ) + url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}" return HttpResponseRedirect(url) except AuthenticationException as e: - params = { - "error_code": str(e.error_code), - "error_message": str(e.error_message), - } + params = e.get_error_dict() if next_path: params["next_path"] = str(next_path) - url = urljoin( - base_host(request=request), - "spaces/accounts/sign-in?" + urlencode(params), - ) + 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..fa20fa618 --- /dev/null +++ b/apiserver/plane/authentication/views/space/password_management.py @@ -0,0 +1,187 @@ +# 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, +) + + +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, + ] + + 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 index 622715ebf..d3f29bd8d 100644 --- a/apiserver/plane/authentication/views/space/signout.py +++ b/apiserver/plane/authentication/views/space/signout.py @@ -1,21 +1,29 @@ -# Python imports -from urllib.parse import urlencode, urljoin - # 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 +from plane.authentication.utils.host import base_host, user_ip +from plane.db.models import User class SignOutAuthSpaceEndpoint(View): def post(self, request): - logout(request) - url = urljoin( - base_host(request=request), - "spaces/accounts/sign-in?" + urlencode({"success": "true"}), - ) - return HttpResponseRedirect(url) + 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/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/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/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 index 9d8cc50be..4698c7120 100644 --- a/apiserver/plane/db/migrations/0065_auto_20240415_0937.py +++ b/apiserver/plane/db/migrations/0065_auto_20240415_0937.py @@ -45,6 +45,51 @@ def migrate_user_profile(apps, schema_editor): ) +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 = [ @@ -257,4 +302,161 @@ class Migration(migrations.Migration): 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/models/__init__.py b/apiserver/plane/db/models/__init__.py index 2dc6d7909..b11ce7aa3 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -98,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/estimate.py b/apiserver/plane/db/models/estimate.py index 5a783f9b9..6ff1186c3 100644 --- a/apiserver/plane/db/models/estimate.py +++ b/apiserver/plane/db/models/estimate.py @@ -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/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/issue.py b/apiserver/plane/db/models/issue.py index e3d1e62a7..7a17853c3 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -120,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, ) diff --git a/apiserver/plane/db/models/page.py b/apiserver/plane/db/models/page.py index edebaf132..3602bce1f 100644 --- a/apiserver/plane/db/models/page.py +++ b/apiserver/plane/db/models/page.py @@ -16,7 +16,7 @@ def get_view_props(): 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) @@ -42,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" diff --git a/apiserver/plane/db/models/view.py b/apiserver/plane/db/models/view.py index d74eb6ca2..87f0899c3 100644 --- a/apiserver/plane/db/models/view.py +++ b/apiserver/plane/db/models/view.py @@ -64,6 +64,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" @@ -86,6 +87,7 @@ class GlobalView(BaseModel): return f"{self.name} <{self.workspace.name}>" +# DEPRECATED TODO: - Remove in next release class IssueView(WorkspaceBaseModel): name = models.CharField(max_length=255, verbose_name="View Name") description = models.TextField(verbose_name="View Description", blank=True) diff --git a/apiserver/plane/db/models/workspace.py b/apiserver/plane/db/models/workspace.py index 56e136126..fe39f2d09 100644 --- a/apiserver/plane/db/models/workspace.py +++ b/apiserver/plane/db/models/workspace.py @@ -325,7 +325,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/instance.py b/apiserver/plane/license/api/serializers/instance.py index 31209ac19..730d388f4 100644 --- a/apiserver/plane/license/api/serializers/instance.py +++ b/apiserver/plane/license/api/serializers/instance.py @@ -8,13 +8,14 @@ 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", diff --git a/apiserver/plane/license/api/views/__init__.py b/apiserver/plane/license/api/views/__init__.py index cddaff0eb..b10702b8a 100644 --- a/apiserver/plane/license/api/views/__init__.py +++ b/apiserver/plane/license/api/views/__init__.py @@ -16,4 +16,5 @@ from .admin import ( InstanceAdminSignUpEndpoint, InstanceAdminUserMeEndpoint, InstanceAdminSignOutEndpoint, + InstanceAdminUserSessionEndpoint, ) diff --git a/apiserver/plane/license/api/views/admin.py b/apiserver/plane/license/api/views/admin.py index c9c028f32..5d93aba49 100644 --- a/apiserver/plane/license/api/views/admin.py +++ b/apiserver/plane/license/api/views/admin.py @@ -28,7 +28,11 @@ 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 +from plane.authentication.utils.host import base_host, user_ip +from plane.authentication.adapter.error import ( + AUTHENTICATION_ERROR_CODES, + AuthenticationException, +) class InstanceAdminEndpoint(BaseAPIView): @@ -95,29 +99,27 @@ class InstanceAdminSignUpEndpoint(View): # 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), - "god-mode/setup?" - + urlencode( - { - "error_code": "INSTANCE_NOT_CONFIGURED", - "error_message": "Instance is not configured", - } - ), + 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), - "god-mode/setup?" - + urlencode( - { - "error_code": "ADMIN_ALREADY_EXIST", - "error_message": "Admin for the instance has been already registered.", - } - ), + base_host(request=request, is_admin=True), + "?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) @@ -131,20 +133,22 @@ class InstanceAdminSignUpEndpoint(View): # 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), - "god-mode/setup?" - + urlencode( - { - "email": email, - "first_name": first_name, - "last_name": last_name, - "company_name": company_name, - "is_telemetry_enabled": is_telemetry_enabled, - "error_code": "REQUIRED_EMAIL_PASSWORD_FIRST_NAME", - "error_message": "Email, name and password are required", - } - ), + base_host(request=request, is_admin=True), + "?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) @@ -153,60 +157,64 @@ class InstanceAdminSignUpEndpoint(View): 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), - "god-mode/setup?" - + urlencode( - { - "email": email, - "first_name": first_name, - "last_name": last_name, - "company_name": company_name, - "is_telemetry_enabled": is_telemetry_enabled, - "error_code": "INVALID_EMAIL", - "error_message": "Please provide a valid email address.", - } - ), + 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), - "god-mode/setup?" - + urlencode( - { - "email": email, - "first_name": first_name, - "last_name": last_name, - "company_name": company_name, - "is_telemetry_enabled": is_telemetry_enabled, - "error_code": "USER_ALREADY_EXISTS", - "error_message": "User already exists.", - } - ), + 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), - "god-mode/setup?" - + urlencode( - { - "email": email, - "first_name": first_name, - "last_name": last_name, - "company_name": company_name, - "is_telemetry_enabled": is_telemetry_enabled, - "error_code": "INVALID_PASSWORD", - "error_message": "Invalid password provided.", - } - ), + base_host(request=request, is_admin=True), + "?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) @@ -235,12 +243,13 @@ class InstanceAdminSignUpEndpoint(View): ) # 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) - url = urljoin(base_host(request=request), "god-mode/general") + user_login(request=request, user=user, is_admin=True) + url = urljoin(base_host(request=request, is_admin=True), "general") return HttpResponseRedirect(url) @@ -254,15 +263,15 @@ class InstanceAdminSignInEndpoint(View): # 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), - "god-mode/login?" - + urlencode( - { - "error_code": "INSTANCE_NOT_CONFIGURED", - "error_message": "Instance is not configured", - } - ), + base_host(request=request, is_admin=True), + "?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) @@ -272,16 +281,18 @@ class InstanceAdminSignInEndpoint(View): # 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), - "god-mode/login?" - + urlencode( - { - "email": email, - "error_code": "REQUIRED_EMAIL_PASSWORD", - "error_message": "Email and password are required", - } - ), + base_host(request=request, is_admin=True), + "?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) @@ -290,64 +301,84 @@ class InstanceAdminSignInEndpoint(View): 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), - "god-mode/login?" - + urlencode( - { - "email": email, - "error_code": "INVALID_EMAIL", - "error_message": "Please provide a valid email address.", - } - ), + 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), - "god-mode/login?" - + urlencode( - { - "email": email, - "error_code": "USER_DOES_NOT_EXIST", - "error_message": "User does not exist", - } - ), + 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), - "god-mode/login?" - + urlencode( - { - "email": email, - "error_code": "AUTHENTICATION_FAILED", - "error_message": "Sorry, we could not find an admin user with the provided credentials. Please try again.", - } - ), + 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), - "god-mode/login?" - + urlencode( - { - "email": email, - "error_code": "AUTHENTICATION_FAILED", - "error_message": "Sorry, we could not find an admin user with the provided credentials. Please try again.", - } - ), + base_host(request=request, is_admin=True), + "?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) # settings last active for the user @@ -360,8 +391,8 @@ class InstanceAdminSignInEndpoint(View): user.save() # get tokens for user - user_login(request=request, user=user) - url = urljoin(base_host(request=request), "god-mode/general") + user_login(request=request, user=user, is_admin=True) + url = urljoin(base_host(request=request, is_admin=True), "general") return HttpResponseRedirect(url) @@ -379,6 +410,30 @@ class InstanceAdminUserMeEndpoint(BaseAPIView): ) +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 = [ @@ -386,9 +441,17 @@ class InstanceAdminSignOutEndpoint(View): ] def post(self, request): - logout(request) - url = urljoin( - base_host(request=request), - "god-mode/login?" + urlencode({"success": "true"}), - ) - return HttpResponseRedirect(url) + # 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/instance.py b/apiserver/plane/license/api/views/instance.py index 40b3c7e0d..525ab54ec 100644 --- a/apiserver/plane/license/api/views/instance.py +++ b/apiserver/plane/license/api/views/instance.py @@ -2,6 +2,7 @@ import os # Django imports +from django.conf import settings # Third party imports from rest_framework import status @@ -37,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( @@ -53,8 +55,6 @@ class InstanceEndpoint(BaseAPIView): IS_GITHUB_ENABLED, GITHUB_APP_NAME, EMAIL_HOST, - EMAIL_HOST_USER, - EMAIL_HOST_PASSWORD, ENABLE_MAGIC_LINK_LOGIN, ENABLE_EMAIL_PASSWORD, SLACK_CLIENT_ID, @@ -80,14 +80,6 @@ class InstanceEndpoint(BaseAPIView): "key": "EMAIL_HOST", "default": os.environ.get("EMAIL_HOST", ""), }, - { - "key": "EMAIL_HOST_USER", - "default": os.environ.get("EMAIL_HOST_USER", ""), - }, - { - "key": "EMAIL_HOST_PASSWORD", - "default": os.environ.get("EMAIL_HOST_PASSWORD", ""), - }, { "key": "ENABLE_MAGIC_LINK_LOGIN", "default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "1"), @@ -148,11 +140,13 @@ class InstanceEndpoint(BaseAPIView): ) # is smtp configured - data["is_smtp_configured"] = ( - bool(EMAIL_HOST) - and bool(EMAIL_HOST_USER) - and bool(EMAIL_HOST_PASSWORD) - ) + 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 diff --git a/apiserver/plane/license/management/commands/register_instance.py b/apiserver/plane/license/management/commands/register_instance.py index 32a37879f..b5cc8a60d 100644 --- a/apiserver/plane/license/management/commands/register_instance.py +++ b/apiserver/plane/license/management/commands/register_instance.py @@ -46,7 +46,7 @@ class Command(BaseCommand): } instance = Instance.objects.create( - instance_name="Plane Free", + instance_name="Plane Community Edition", instance_id=secrets.token_hex(12), license_key=None, api_key=secrets.token_hex(8), diff --git a/apiserver/plane/license/urls.py b/apiserver/plane/license/urls.py index b95ae74d6..b4f19e52c 100644 --- a/apiserver/plane/license/urls.py +++ b/apiserver/plane/license/urls.py @@ -10,6 +10,7 @@ from plane.license.api.views import ( SignUpScreenVisitedEndpoint, InstanceAdminUserMeEndpoint, InstanceAdminSignOutEndpoint, + InstanceAdminUserSessionEndpoint, ) urlpatterns = [ @@ -28,6 +29,11 @@ urlpatterns = [ InstanceAdminUserMeEndpoint.as_view(), name="instance-admins", ), + path( + "admins/session/", + InstanceAdminUserSessionEndpoint.as_view(), + name="instance-admin-session", + ), path( "admins/sign-out/", InstanceAdminSignOutEndpoint.as_view(), 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 908ef446c..ed756642b 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -78,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 @@ -150,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"), } } @@ -265,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", ) @@ -326,16 +329,24 @@ DATA_UPLOAD_MAX_MEMORY_SIZE = int(os.environ.get("FILE_SIZE_LIMIT", 5242880)) SESSION_COOKIE_SECURE = secure_origins SESSION_COOKIE_HTTPONLY = True SESSION_ENGINE = "plane.db.models.session" -SESSION_COOKIE_AGE = 604800 +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 = True +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) + +# 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 4f67e638b..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,17 +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", - "http://localhost:3333", - "http://127.0.0.1:3333", -] -CSRF_TRUSTED_ORIGINS = CORS_ALLOWED_ORIGINS -CORS_ALLOW_ALL_ORIGINS = True - 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/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 new file mode 100644 index 000000000..c946cfb27 --- /dev/null +++ b/apiserver/plane/utils/user_timezone_converter.py @@ -0,0 +1,25 @@ +import pytz + +def user_timezone_converter(queryset, datetime_fields, user_timezone): + # Create a timezone object for the user's timezone + user_tz = pytz.timezone(user_timezone) + + # Check if queryset is a dictionary (single item) or a list of dictionaries + if isinstance(queryset, dict): + queryset_values = [queryset] + else: + 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 field in item and item[field]: + item[field] = item[field].astimezone(user_tz) + + # If queryset was a single item, return a single item + if isinstance(queryset, dict): + return queryset_values[0] + else: + return queryset_values diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index e33d580de..e7651ebfc 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 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/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 - - - - - - + + + + + + - - + +