From 59335618b4ebf27cbd4058420f1ddcee415e47c6 Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Wed, 8 May 2024 23:01:20 +0530 Subject: [PATCH] feat: session auth implementation (#4411) * feat: session authentication and god-mode implementation (#4302) * dev: move authentication to base class for credentials * chore: new account creation * dev: return error as query parameter * dev: accounts and profile endpoints for user * fix: user store updates * fix: store fixes * fix: type fixes * dev: set is_password_autoset and is_email_verifier for auth providers * dev: move all auth configuration to different apps * dev: fix circular imports * dev: remove unused imports * dev: fix imports for authentication * dev: update endpoints to use rest framework api viewa * fix: onboarding fixes * dev: session model changes * fix: session model and add check for last name first name and avatar * dev: fix referer redirect * dev: remove auth imports * dev: fix imports * dev: update migrations * fix: instance admin login * comflict: conflicts resolved * dev: fix import errors and email check endpoint * fix: error messages and redirects after login * dev: configs api * fix: is github enabled boolean * dev: merge config and instance api * conflict: merge conflict resolved * dev: instance admin sign up endpoint * dev: enable magic link login * dev: configure instance variables for github and google enabled * chore: typo fixes * fix: god mode docker file changes * build-error: resolved build errors * fix: docker compose changes * dev: add email credential check endpoint * fix: minor package changes * fix: docker related changes * dev: add nginx rules in the nginx template * dev: refactor the url patterns * fix: docker changes * fix: docker files for god-mode * fix: static export * fix: nginx conf * dev: smtp sender refused exception * fix: godmode fixes * chore: god mode revamp. * dev: add csrf secured flag * fix: oauth redirect uri and session settings * chore: god mode app changes. (#3982) * chore: send test email functionality. * style: authentication methods page UI revamp. * chore: create workspace popup. * fix: user me endpoint * dev: fix redirection after authentication * dev: handle god mode redirection * fix: redirections * fix: auth related hooks * fix: store related fixes * dev: fix session authentication for rest apis * fix: linting errors * fix: removing references of useStore= * dev: fix redirection and password validation * dev: add useUser hook * fix: build fixes and lint issues * fix: removing useApplication hook * fix: build errors * fix: delete unused files * fix: auth build fixes * fix: bugfixes * dev: alter avatar to support more than 255 chars * dev: fix profile endpoint and increase session expiry time and update session on every request * chore: resolved the migration * chore: resolved merge conflicts * dev: error codes and error messages for the auth flow * dev: instance admin sign up and sign in endpoint * dev: use zxcvbn to validate password strength * dev: add extra parameters when error handling on instance god mode * chore: auth init * chore: signin/ signup form ui updates and password strength meter. * chore: update password fields. * chore: validations and error handling. * chore: updated sign-up form * chore: updated workflow and updated the code structure * chore: instance empty state for god-mode. * chore: instance and auth wrappers update * fix: renaming godmode * fix: docker changes * chore: updated authentication wrappers * chore: updated the authentication workflow and rendered all pages * fix: build errors * fix: docker related fixes * fix: tailing slash added to space and admin for valid nginx locations * chore: seperate pages for signup and login * git-action modified for admin file changes * feature build action updated for admin app * self host modified * chore: resolved build errors and handled signin and signup in a seperate route * chore: sign-in and sign-up revamp. * fix: migration conflicts * dev: migrations * chore: handled redirection * dev: admin url * dev: create seperate endpoint for instance admin me * dev: instance admin endpoint * git action fixed * chore: handled auth wrappers * dev: add serializer and remove print logs * fix: build errors * dev: fix migrations * dev: instance folder structuring * fix: linting errors * chore: resolved build errors * chore: updated store and auth workflow and updates api service types * chore: Replaced Next Link with Anchoer tag for god-mode redirection * add 3333 port to allowed origins * make password login working again * dev: fix redirection, add admin signout endpoint and fix email credential check endpoint * fix unique code sign in * fix small build error * enable sign out * dev: add google client secret variable to configure instance * dev: add referer for redirection * fix origin urls for oauths * admin setup and login separation * dev: fix user redirection and tour completed endpoint * fix build errors * dev: add set password endpoint * dev: remove user creation logic for redirection * fix unique code page * fix forgot password * chore: onboarding revamp. * dev: fix workspace slug redirection in login * chore: invited user onboarding flow update. * chore: fix switch or delete account modal. * fix members exception * refactor auth flows and add invitations to auth flow * fix sig in sign up url * fix action url * fix build errors * dev: fix user set password when logging in * dev: reset password endpoint * chore: confirm password validation for signup and onboarding. * enable reset password * fix build error * chore: minor UI updates. * chore: forgot and reset password UI revamp. * fix authentication re directions * dev: auth redirections * change url paths for signup and signin * dev: make the user logged in when changing passwords * dev: next path redirection for web and space app * dev: next path for magic sign in endpoint * dev: github space endpoint * chore: minor ui updates and fixes in web app. * set password screen * fix multiple unique code generation * dev: next path base redirection * dev: remove print logs * dev: auth space endpoints * fix build errors * dev: invalidate cache on configuration update, god mode exception errors and authentication failed code * dev: fix space endpoints and add extra endpoints * chore: space auth revamp. * dev: add sign up for space app * fix: build errors. * fix: auth redirection logic. * chore: space app onboarding revamp. --------- Co-authored-by: pablohashescobar Co-authored-by: NarayanBavisetti Co-authored-by: gurusainath Co-authored-by: Prateek Shourya Co-authored-by: Manish Gupta <59428681+mguptahub@users.noreply.github.com> Co-authored-by: Manish Gupta Co-authored-by: = <=> Co-authored-by: rahulramesha * chore: updated file structure for admin * chore: updated admin-sidebar * chore: auth error handling * chore: onboarding UI updates and dark mode fixes. * chore: add `user personalization` step to onboarding profile setup screen. * chore: fix minor UI bugs * chore: authentication workflow changes * chore: handled signin workflow * style: switch or delete account workflow * chore: god mode redirection URL * feat(dashboard): improve label readability (#4321) change none label for all time in dashbard filters * chore: god-mode redirection * chore: onboarding ui updates and accept invitation workflow updates. * chore: rename unique code auth form. * style: space auth ux copy. * chore: updated intance and auth wrapper logic * chore: update default layout style. * chore: update confirm password. * chore: backend redirection * style: update banner ui * chore: minor ui updates and validation fix. * chore: removed old auth hook * chore: handled auth wrapper * chore: handled store loaders in the user * chore: handled logs * chore: add loading spinners for all auth and onboarding form buttons. * chore: add background pattern in admin auth forms and minor ui fixes. * chore: UI changes and revamp components for authentication * chore: auth UI consistency in web, space and admin. * chore: resolved build errors * chore: removed old auth hooks * chore: handled lint errors in use accounts * chore: updated authentication wrapper logic in web app * [WEB -1149] dev: update dependencies (#4333) * dev: upgrade dependencies remove unwanted dependency and add ruff as local dependency * dev: add comments * chore: authentication wrapper fetch user * chore: updated store loader * chore: removed old auth wrapper and replaced the imports with new auth wrapper * chore: join workspace invitation workflow updates * chore: build error resolved in deploy * chore: handled onboarding step error in web app * chore: SMTP Name and Password validation removed * chore: handled seo and signout logic and new user popup * chore: added redirection to plane in the sidebar * chore: resolved build errors * dev: admin session cookie update * chore: updated cookie session time for admin * dev: add start date and end date to projects (#4355) * chore: add email security dropdown and remove SMTP username and password validation. * chore: add tooltip to admin sidebar help-section. * chore: add dropdown to collapsed admin sidebar. * chore: profile themning * chore: updated page error messages and theme in command palette * dev: add email validation in email check apis * dev: remove start date and end date from project * chore: updated space folder structure and updated the store hooks * dev: error codes for authentication * chore: handled authentication in space and web apps * chore: banner redirect handling the email * dev: god mode error codes * chore: updated error codes * chore: updated onboarding images * dev: signout endpoints and saving login domain while creating sessions * feat: Self Host Data Backup (#4383) * feat: implemented backup , support for docker-compose tool, readme updated * minor fix in shell script * codacy fixes * chore: handled build errors in web * chore: updated react, react-dom, and next versions * chore: updated password autioset in the signin * dev: add logo prop to views and pages * chore: updated api service and handled the set password in store * chore: handled build errors and code cleanup * dev: return 401 when the session is not valid * dev: users/me exception for api * chore: installed lodash in space app * dev: add auth route in nginx --------- Co-authored-by: pablohashescobar Co-authored-by: NarayanBavisetti Co-authored-by: gurusainath Co-authored-by: Prateek Shourya Co-authored-by: Manish Gupta <59428681+mguptahub@users.noreply.github.com> Co-authored-by: Manish Gupta Co-authored-by: rahulramesha Co-authored-by: Aaryan Khandelwal Co-authored-by: Daniel Alba <56451942+redrum15@users.noreply.github.com> Co-authored-by: Nikhil <118773738+pablohashescobar@users.noreply.github.com> --- .github/workflows/build-branch.yml | 60 + .github/workflows/feature-deployment.yml | 67 +- .gitignore | 1 + admin/.env.example | 2 + admin/.eslintrc.js | 14 + admin/.prettierignore | 6 + admin/.prettierrc | 5 + admin/Dockerfile.admin | 51 + admin/app/ai/components/ai-config-form.tsx | 128 + admin/app/ai/components/index.ts | 1 + admin/app/ai/layout.tsx | 21 + admin/app/ai/page.tsx | 47 + .../common/authentication-method-card.tsx | 51 + .../authentication/components/common/index.ts | 1 + .../components/email-config-switch.tsx | 36 + admin/app/authentication/components/index.ts | 3 + .../components/password-config-switch.tsx | 36 + .../github/components/github-config-form.tsx | 206 + .../authentication/github/components/index.ts | 2 + .../authentication/github/components/root.tsx | 59 + admin/app/authentication/github/page.tsx | 113 + .../google/components/google-config-form.tsx | 206 + .../authentication/google/components/index.ts | 2 + .../authentication/google/components/root.tsx | 59 + admin/app/authentication/google/page.tsx | 101 + admin/app/authentication/layout.tsx | 21 + admin/app/authentication/page.tsx | 154 + .../email/components/email-config-form.tsx | 221 + admin/app/email/components/index.ts | 2 + .../app/email/components/test-email-modal.tsx | 135 + admin/app/email/layout.tsx | 21 + admin/app/email/page.tsx | 50 + .../components/general-config-form.tsx | 136 + admin/app/general/components/index.ts | 1 + admin/app/general/layout.tsx | 21 + admin/app/general/page.tsx | 34 + admin/app/globals.css | 466 ++ .../image/components/image-config-form.tsx | 79 + admin/app/image/components/index.ts | 1 + admin/app/image/layout.tsx | 21 + admin/app/image/page.tsx | 43 + admin/app/layout.tsx | 48 + .../app/login/components}/index.ts | 1 - admin/app/login/components/sign-in-form.tsx | 177 + admin/app/login/layout.tsx | 19 + admin/app/login/page.tsx | 18 + admin/app/page.tsx | 20 + admin/app/setup/components/index.ts | 1 + admin/app/setup/components/sign-up-form.tsx | 331 ++ admin/app/setup/layout.tsx | 19 + admin/app/setup/page.tsx | 16 + .../admin-sidebar}/help-section.tsx | 92 +- admin/components/admin-sidebar/index.ts | 5 + admin/components/admin-sidebar/root.tsx | 57 + .../admin-sidebar/sidebar-dropdown.tsx | 147 + .../sidebar-menu-hamburger-toogle.tsx | 20 + .../components/admin-sidebar/sidebar-menu.tsx | 104 + admin/components/auth-header.tsx | 90 + admin/components/common/banner.tsx | 32 + admin/components/common/breadcrumb-link.tsx | 36 + .../common/confirm-discard-modal.tsx | 83 + admin/components/common/controller-input.tsx | 82 + admin/components/common/copy-field.tsx | 46 + admin/components/common/index.ts | 6 + .../common/password-strength-meter.tsx | 69 + admin/components/core/index.ts | 1 + admin/components/core/page-header.tsx | 17 + admin/components/instance/index.ts | 1 + .../instance/instance-not-ready.tsx | 30 + admin/components/new-user-popup.tsx | 54 + admin/constants/seo.ts | 8 + admin/constants/swr-config.ts | 8 + admin/helpers/authentication.helper.tsx | 126 + admin/helpers/common.helper.ts | 9 + admin/helpers/index.ts | 2 + admin/helpers/instance.helper.ts | 9 + admin/helpers/password.helper.ts | 16 + admin/helpers/user.helper.ts | 21 + admin/hooks/index.ts | 6 + admin/hooks/store/use-instance.tsx | 10 + admin/hooks/store/use-theme.tsx | 10 + admin/hooks/store/use-user.tsx | 10 + admin/hooks/use-outside-click-detector.tsx | 21 + admin/layouts/admin-layout.tsx | 24 + admin/layouts/default-layout.tsx | 43 + admin/layouts/index.ts | 2 + admin/lib/store-context.tsx | 21 + admin/lib/wrappers/app-wrapper.tsx | 36 + admin/lib/wrappers/auth-wrapper.tsx | 59 + admin/lib/wrappers/index.ts | 3 + admin/lib/wrappers/instance-wrapper.tsx | 59 + admin/next-env.d.ts | 5 + admin/next.config.js | 13 + admin/package.json | 49 + admin/postcss.config.js | 8 + admin/public/auth/background-pattern-dark.svg | 68 + admin/public/auth/background-pattern.svg | 68 + .../public/favicon/android-chrome-192x192.png | Bin 0 -> 2222 bytes .../public/favicon/android-chrome-512x512.png | Bin 0 -> 3832 bytes admin/public/favicon/apple-touch-icon.png | Bin 0 -> 2176 bytes admin/public/favicon/favicon-16x16.png | Bin 0 -> 1625 bytes admin/public/favicon/favicon-32x32.png | Bin 0 -> 1617 bytes admin/public/favicon/favicon.ico | Bin 0 -> 919 bytes admin/public/favicon/site.webmanifest | 11 + admin/public/images/plane-takeoff.png | Bin 0 -> 47818 bytes admin/public/logos/github-black.png | Bin 0 -> 14032 bytes admin/public/logos/github-white.png | Bin 0 -> 16559 bytes admin/public/logos/google-logo.svg | 1 + admin/public/logos/takeoff-icon-dark.svg | 35 + admin/public/logos/takeoff-icon-light.svg | 40 + .../public/plane-logos/blue-without-text.png | Bin 0 -> 2460 bytes admin/public/site.webmanifest.json | 13 + admin/services/api.service.ts | 50 + admin/services/auth.service.ts | 22 + admin/services/index.ts | 3 + admin/services/instance.service.ts | 66 + admin/services/user.service.ts | 20 + admin/store/instance.store.ts | 163 + admin/store/root-store.ts | 25 + admin/store/theme.store.ts | 63 + admin/store/user.store.ts | 83 + admin/tailwind.config.js | 5 + admin/tsconfig.json | 18 + apiserver/plane/app/serializers/__init__.py | 2 + apiserver/plane/app/serializers/user.py | 45 +- apiserver/plane/app/urls/__init__.py | 9 +- apiserver/plane/app/urls/authentication.py | 65 - apiserver/plane/app/urls/config.py | 17 - apiserver/plane/app/urls/user.py | 44 +- apiserver/plane/app/views/__init__.py | 25 +- apiserver/plane/app/views/analytic/base.py | 11 +- apiserver/plane/app/views/auth_extended.py | 482 -- apiserver/plane/app/views/authentication.py | 453 -- apiserver/plane/app/views/base.py | 9 + apiserver/plane/app/views/config.py | 248 - apiserver/plane/app/views/oauth.py | 458 -- apiserver/plane/app/views/user/base.py | 76 +- apiserver/plane/authentication/__init__.py | 0 .../plane/authentication/adapter/__init__.py | 0 .../plane/authentication/adapter/base.py | 115 + .../authentication/adapter/credential.py | 14 + .../plane/authentication/adapter/error.py | 71 + .../plane/authentication/adapter/exception.py | 18 + .../plane/authentication/adapter/oauth.py | 88 + apiserver/plane/authentication/apps.py | 5 + .../authentication/middleware/__init__.py | 0 .../authentication/middleware/session.py | 94 + .../plane/authentication/provider/__init__.py | 0 .../provider/credentials/__init__.py | 0 .../provider/credentials/email.py | 97 + .../provider/credentials/magic_code.py | 130 + .../authentication/provider/oauth/__init__.py | 0 .../authentication/provider/oauth/github.py | 136 + .../authentication/provider/oauth/google.py | 117 + apiserver/plane/authentication/session.py | 8 + apiserver/plane/authentication/urls.py | 184 + apiserver/plane/authentication/utils/host.py | 14 + apiserver/plane/authentication/utils/login.py | 17 + .../authentication/utils/redirection_path.py | 42 + .../utils/workspace_project_join.py | 72 + .../plane/authentication/views/__init__.py | 52 + .../plane/authentication/views/app/check.py | 148 + .../plane/authentication/views/app/email.py | 240 + .../plane/authentication/views/app/github.py | 133 + .../plane/authentication/views/app/google.py | 128 + .../plane/authentication/views/app/magic.py | 211 + .../plane/authentication/views/app/signout.py | 34 + .../plane/authentication/views/common.py | 326 ++ .../plane/authentication/views/space/check.py | 82 + .../plane/authentication/views/space/email.py | 224 + .../authentication/views/space/github.py | 125 + .../authentication/views/space/google.py | 120 + .../plane/authentication/views/space/magic.py | 196 + .../authentication/views/space/signout.py | 34 + .../plane/bgtasks/forgot_password_task.py | 1 + .../plane/bgtasks/magic_link_code_task.py | 1 + .../db/management/commands/reset_password.py | 12 +- .../db/migrations/0065_auto_20240415_0937.py | 270 + apiserver/plane/db/models/__init__.py | 142 +- apiserver/plane/db/models/asset.py | 7 +- apiserver/plane/db/models/cycle.py | 4 +- apiserver/plane/db/models/dashboard.py | 2 +- apiserver/plane/db/models/estimate.py | 4 +- apiserver/plane/db/models/exporter.py | 7 +- apiserver/plane/db/models/importer.py | 4 +- apiserver/plane/db/models/inbox.py | 2 +- .../plane/db/models/integration/github.py | 2 +- .../plane/db/models/integration/slack.py | 2 +- apiserver/plane/db/models/issue.py | 11 +- apiserver/plane/db/models/module.py | 4 +- apiserver/plane/db/models/notification.py | 5 +- apiserver/plane/db/models/page.py | 9 +- apiserver/plane/db/models/project.py | 6 +- apiserver/plane/db/models/session.py | 65 + .../plane/db/models/social_connection.py | 4 +- apiserver/plane/db/models/state.py | 2 +- apiserver/plane/db/models/user.py | 102 +- apiserver/plane/db/models/view.py | 8 +- apiserver/plane/db/models/workspace.py | 5 +- .../plane/license/api/serializers/__init__.py | 5 +- .../plane/license/api/serializers/admin.py | 41 + .../plane/license/api/serializers/base.py | 5 + .../license/api/serializers/configuration.py | 17 + .../plane/license/api/serializers/instance.py | 30 +- apiserver/plane/license/api/views/__init__.py | 18 +- apiserver/plane/license/api/views/admin.py | 421 ++ apiserver/plane/license/api/views/base.py | 132 + .../plane/license/api/views/configuration.py | 168 + apiserver/plane/license/api/views/instance.py | 326 +- .../management/commands/configure_instance.py | 84 + apiserver/plane/license/urls.py | 28 +- apiserver/plane/settings/common.py | 66 +- apiserver/plane/settings/local.py | 4 + apiserver/plane/urls.py | 6 +- apiserver/requirements/base.txt | 92 +- apiserver/requirements/local.txt | 6 +- apiserver/requirements/production.txt | 2 +- apiserver/requirements/test.txt | 2 +- deploy/selfhost/README.md | 176 +- deploy/selfhost/build.yml | 6 + deploy/selfhost/docker-compose.yml | 12 + deploy/selfhost/install.sh | 65 +- deploy/selfhost/variables.env | 1 + docker-compose.yml | 40 +- nginx/.prettierignore | 1 + nginx/nginx.conf.dev | 8 + nginx/nginx.conf.template | 11 +- package.json | 3 +- .../tailwind-config-custom/tailwind.config.js | 1 + packages/types/src/app.d.ts | 14 - packages/types/src/auth.d.ts | 7 +- packages/types/src/current-user/accounts.d.ts | 17 + packages/types/src/current-user/index.ts | 3 + packages/types/src/current-user/profile.d.ts | 29 + packages/types/src/current-user/user.d.ts | 30 + packages/types/src/index.d.ts | 14 +- packages/types/src/instance.d.ts | 47 - packages/types/src/instance/ai.d.ts | 1 + packages/types/src/instance/auth.d.ts | 22 + packages/types/src/instance/base.d.ts | 79 + packages/types/src/instance/email.d.ts | 8 + packages/types/src/instance/image.d.ts | 1 + packages/types/src/instance/index.d.ts | 5 + packages/types/src/users.d.ts | 79 +- packages/ui/src/button/helper.tsx | 9 +- space/Dockerfile.space | 2 +- .../components/accounts/auth-forms/email.tsx | 92 + .../auth-forms}/forgot-password-popover.tsx | 0 .../components/accounts/auth-forms}/index.ts | 3 +- .../accounts/auth-forms/password.tsx | 216 + space/components/accounts/auth-forms/root.tsx | 156 + .../accounts/auth-forms/unique-code.tsx | 188 + space/components/accounts/github-sign-in.tsx | 58 - space/components/accounts/google-sign-in.tsx | 60 - space/components/accounts/index.ts | 8 +- .../accounts/oauth/github-button.tsx | 39 + .../accounts/oauth/google-button.tsx | 33 + space/components/accounts/oauth/index.ts | 3 + .../accounts/oauth/oauth-options.tsx | 28 + space/components/accounts/onboarding-form.tsx | 319 +- .../accounts/password-strength-meter.tsx | 67 + .../sign-in-forms/create-password.tsx | 141 - .../accounts/sign-in-forms/email-form.tsx | 122 - .../accounts/sign-in-forms/index.ts | 9 - .../accounts/sign-in-forms/o-auth-options.tsx | 86 - .../sign-in-forms/optional-set-password.tsx | 104 - .../accounts/sign-in-forms/password.tsx | 233 - .../accounts/sign-in-forms/root.tsx | 120 - .../sign-in-forms/self-hosted-sign-in.tsx | 144 - .../sign-in-forms/set-password-link.tsx | 103 - .../accounts/sign-in-forms/unique-code.tsx | 263 - .../accounts/terms-and-conditions.tsx | 31 + .../accounts/user-image-upload-modal.tsx | 185 + space/components/accounts/user-logged-in.tsx | 9 +- space/components/instance/index.ts | 1 + space/components/instance/not-ready-view.tsx | 42 + .../issues/board-views/kanban/block.tsx | 4 +- .../issues/board-views/kanban/header.tsx | 4 +- .../issues/board-views/kanban/index.tsx | 4 +- .../issues/board-views/list/block.tsx | 4 +- .../issues/board-views/list/header.tsx | 4 +- .../issues/board-views/list/index.tsx | 4 +- .../issues/filters/applied-filters/root.tsx | 4 +- space/components/issues/filters/root.tsx | 4 +- space/components/issues/navbar/index.tsx | 15 +- .../issues/navbar/issue-board-view.tsx | 4 +- .../peek-overview/comment/add-comment.tsx | 11 +- .../comment/comment-detail-card.tsx | 11 +- .../comment/comment-reactions.tsx | 33 +- .../issues/peek-overview/header.tsx | 4 +- .../issues/peek-overview/issue-activity.tsx | 19 +- .../peek-overview/issue-emoji-reactions.tsx | 25 +- .../issues/peek-overview/issue-reaction.tsx | 2 +- .../peek-overview/issue-vote-reactions.tsx | 26 +- .../issues/peek-overview/layout.tsx | 2 +- space/components/views/auth.tsx | 64 + space/components/views/index.ts | 3 +- space/components/views/login.tsx | 62 - space/components/views/project-details.tsx | 18 +- space/helpers/authentication.helper.tsx | 295 ++ space/helpers/password.helper.ts | 16 + space/hooks/store/index.ts | 4 + space/hooks/store/use-instance.ts | 10 + space/hooks/store/user-mobx-provider.ts | 10 + space/hooks/store/user/index.ts | 2 + space/hooks/store/user/use-user-profile.ts | 10 + space/hooks/store/user/use-user.ts | 10 + space/hooks/use-editor-suggestions.tsx | 4 +- space/hooks/use-mention.tsx | 3 +- space/hooks/use-sign-in-redirection.tsx | 66 - space/lib/mobx/store-init.tsx | 18 - space/lib/mobx/store-provider.tsx | 28 - space/lib/store-context.tsx | 19 + space/lib/wrappers/auth-wrapper.tsx | 86 + space/lib/wrappers/index.ts | 2 + space/lib/wrappers/instance-wrapper.tsx | 34 + space/next.config.js | 6 +- space/package.json | 14 +- .../[workspace_slug]/[project_slug]/index.tsx | 30 +- space/pages/_app.tsx | 21 +- space/pages/accounts/forgot-password.tsx | 166 + space/pages/accounts/password.tsx | 180 - space/pages/accounts/reset-password.tsx | 205 + space/pages/index.tsx | 32 +- space/pages/onboarding/index.tsx | 140 +- space/pages/project-not-published/index.tsx | 49 +- space/public/auth/background-pattern-dark.svg | 68 + space/public/auth/background-pattern.svg | 68 + .../instance/plane-instance-not-ready.webp | Bin 0 -> 45894 bytes space/public/logos/github-black.png | Bin 0 -> 14032 bytes space/public/logos/github-black.svg | 29 - space/public/logos/github-dark.svg | 3 + space/public/logos/google-logo.svg | 1 + .../public/onboarding/profile-setup-dark.svg | 220 + space/public/onboarding/profile-setup.svg | 222 + space/services/api.service.ts | 117 +- space/services/app-config.service.ts | 24 - space/services/authentication.service.ts | 144 +- space/services/file.service.ts | 15 +- space/services/instance.service.ts | 20 + space/services/user.service.ts | 25 +- space/store/instance.store.ts | 69 + space/store/issue.ts | 2 +- space/store/issue_details.ts | 15 +- space/store/issues/base-issue-filter.store.ts | 2 +- space/store/issues/issue-filters.store.ts | 2 +- space/store/mentions.store.ts | 6 +- space/store/project.ts | 24 +- space/store/{root.ts => root.store.ts} | 35 +- space/store/user.ts | 101 - space/store/user/index.store.ts | 164 + space/store/user/profile.store.ts | 134 + space/types/{app.ts => app.d.ts} | 0 space/types/{auth.ts => auth.d.ts} | 0 space/types/{issue.ts => issue.d.ts} | 0 space/types/{project.ts => project.d.ts} | 0 space/types/{theme.ts => theme.d.ts} | 0 space/types/{user.ts => user.d.ts} | 0 turbo.json | 2 + web/Dockerfile.web | 1 + .../account/auth-forms/auth-banner.tsx | 29 + .../account/auth-forms/auth-header.tsx | 108 + .../account/auth-forms/auth-root.tsx | 150 + web/components/account/auth-forms/email.tsx | 77 + .../auth-forms/forgot-password-popover.tsx | 54 + web/components/account/auth-forms/index.ts | 9 + .../account/auth-forms/password.tsx | 224 + .../account/auth-forms/unique-code.tsx | 177 + .../account/deactivate-account-modal.tsx | 2 +- web/components/account/index.ts | 6 +- .../account/o-auth/github-sign-in.tsx | 62 - .../account/o-auth/google-sign-in.tsx | 60 - web/components/account/o-auth/index.ts | 3 - .../account/o-auth/o-auth-options.tsx | 89 - .../account/oauth/github-button.tsx | 39 + .../account/oauth/google-button.tsx | 33 + web/components/account/oauth/index.ts | 3 + .../account/oauth/oauth-options.tsx | 28 + .../account/password-strength-meter.tsx | 67 + .../account/sign-in-forms/email.tsx | 107 - .../sign-in-forms/optional-set-password.tsx | 189 - .../account/sign-in-forms/password.tsx | 232 - web/components/account/sign-in-forms/root.tsx | 131 - .../account/sign-in-forms/unique-code.tsx | 217 - .../account/sign-up-forms/email.tsx | 107 - web/components/account/sign-up-forms/index.ts | 5 - .../sign-up-forms/optional-set-password.tsx | 194 - .../account/sign-up-forms/password.tsx | 153 - web/components/account/sign-up-forms/root.tsx | 106 - .../account/sign-up-forms/unique-code.tsx | 222 - .../custom-analytics/custom-analytics.tsx | 14 +- .../analytics/custom-analytics/select-bar.tsx | 2 +- .../custom-analytics/select/project.tsx | 2 +- .../sidebar/projects-list.tsx | 2 +- .../sidebar/sidebar-header.tsx | 2 +- .../custom-analytics/sidebar/sidebar.tsx | 30 +- .../analytics/project-modal/header.tsx | 2 +- .../analytics/project-modal/main-content.tsx | 2 +- .../analytics/project-modal/modal.tsx | 2 +- .../auth-screens/not-authorized-view.tsx | 6 +- .../automation/auto-archive-automation.tsx | 2 +- .../automation/auto-close-automation.tsx | 2 +- web/components/breadcrumbs/index.tsx | 68 - .../command-palette/actions/help-actions.tsx | 12 +- .../actions/issue-actions/actions-list.tsx | 20 +- .../actions/issue-actions/change-assignee.tsx | 2 +- .../actions/issue-actions/change-priority.tsx | 2 +- .../actions/issue-actions/change-state.tsx | 2 +- .../actions/project-actions.tsx | 9 +- .../command-palette/actions/theme-actions.tsx | 23 +- .../command-palette/command-modal.tsx | 19 +- .../command-palette/command-palette.tsx | 26 +- web/components/core/activity.tsx | 2 +- web/components/core/image-picker-popover.tsx | 28 +- .../modals/bulk-delete-issues-modal-item.tsx | 2 +- .../core/modals/bulk-delete-issues-modal.tsx | 2 +- .../core/modals/user-image-upload-modal.tsx | 16 +- .../modals/workspace-image-upload-modal.tsx | 17 +- web/components/core/sidebar/index.ts | 1 + .../sidebar/sidebar-menu-hamburger-toggle.tsx | 12 +- .../core/theme/custom-theme-selector.tsx | 50 +- web/components/cycles/active-cycle/root.tsx | 8 +- web/components/cycles/cycle-peek-overview.tsx | 2 +- web/components/cycles/cycles-view.tsx | 12 +- web/components/cycles/delete-modal.tsx | 2 +- web/components/cycles/gantt-chart/blocks.tsx | 10 +- .../cycles/gantt-chart/cycles-list-layout.tsx | 2 +- web/components/cycles/sidebar.tsx | 2 +- .../cycles/transfer-issues-modal.tsx | 2 +- .../dashboard/home-dashboard-widgets.tsx | 12 +- .../dashboard/project-empty-state.tsx | 18 +- .../dashboard/widgets/assigned-issues.tsx | 2 +- .../dashboard/widgets/created-issues.tsx | 2 +- .../widgets/issue-panels/issue-list-item.tsx | 54 +- .../dashboard/widgets/issues-by-priority.tsx | 2 +- .../widgets/issues-by-state-group.tsx | 18 +- .../dashboard/widgets/overview-stats.tsx | 2 +- .../dashboard/widgets/recent-activity.tsx | 12 +- .../collaborators-list.tsx | 12 +- .../dashboard/widgets/recent-projects.tsx | 35 +- .../dropdowns/cycle/cycle-options.tsx | 14 +- web/components/dropdowns/cycle/index.tsx | 12 +- web/components/dropdowns/estimate.tsx | 21 +- web/components/dropdowns/member/avatar.tsx | 2 +- web/components/dropdowns/member/index.tsx | 12 +- .../dropdowns/member/member-options.tsx | 22 +- web/components/dropdowns/module/index.tsx | 2 +- .../dropdowns/module/module-options.tsx | 17 +- web/components/dropdowns/project.tsx | 2 +- web/components/dropdowns/state.tsx | 16 +- .../lite-text-editor/lite-text-editor.tsx | 2 +- .../rich-text-editor/rich-text-editor.tsx | 2 +- .../create-update-estimate-modal.tsx | 2 +- .../estimates/delete-estimate-modal.tsx | 2 +- .../estimates/estimate-list-item.tsx | 2 +- web/components/estimates/estimates-list.tsx | 2 +- web/components/exporter/export-modal.tsx | 2 +- web/components/exporter/guide.tsx | 23 +- web/components/headers/cycle-issues.tsx | 22 +- web/components/headers/cycles.tsx | 6 +- web/components/headers/module-issues.tsx | 22 +- web/components/headers/modules-list.tsx | 8 +- web/components/headers/page-details.tsx | 19 +- web/components/headers/pages.tsx | 10 +- .../project-archived-issue-details.tsx | 2 +- .../headers/project-draft-issues.tsx | 2 +- web/components/headers/project-inbox.tsx | 2 +- .../headers/project-issue-details.tsx | 14 +- web/components/headers/project-issues.tsx | 8 +- web/components/headers/project-settings.tsx | 2 +- .../headers/project-view-issues.tsx | 10 +- web/components/headers/project-views.tsx | 10 +- web/components/headers/projects.tsx | 13 +- web/components/headers/user-profile.tsx | 34 +- .../headers/workspace-active-cycles.tsx | 2 +- .../headers/workspace-analytics.tsx | 22 +- web/components/headers/workspace-settings.tsx | 2 +- .../inbox/content/inbox-issue-header.tsx | 3 +- .../inbox/content/issue-properties.tsx | 22 +- web/components/inbox/content/issue-root.tsx | 2 +- .../inbox/inbox-filter/filters/members.tsx | 2 +- web/components/instance/ai-form.tsx | 145 - web/components/instance/email-form.tsx | 255 - web/components/instance/general-form.tsx | 124 - .../instance/github-config-form.tsx | 177 - .../instance/google-config-form.tsx | 126 - web/components/instance/image-config-form.tsx | 111 - web/components/instance/index.ts | 13 - .../instance/instance-admin-restriction.tsx | 77 - web/components/instance/not-ready-view.tsx | 59 +- web/components/instance/setup-done-view.tsx | 67 - web/components/instance/setup-form/root.tsx | 30 - .../instance/setup-form/sign-in-form.tsx | 140 - web/components/instance/setup-view.tsx | 36 - web/components/instance/sidebar-dropdown.tsx | 156 - web/components/instance/sidebar-menu.tsx | 101 - web/components/integration/github/auth.tsx | 20 +- .../integration/github/import-data.tsx | 2 +- web/components/integration/guide.tsx | 23 +- .../integration/jira/give-details.tsx | 16 +- .../integration/single-integration-card.tsx | 14 +- .../integration/slack/select-channel.tsx | 21 +- .../issues/archived-issues-header.tsx | 2 +- .../issues/attachment/attachment-upload.tsx | 12 +- .../issues/attachment/attachments-list.tsx | 2 +- .../issues/issue-detail/cycle-select.tsx | 2 +- .../issue-activity/activity-comment-root.tsx | 2 +- .../issue-activity/activity/activity-list.tsx | 2 +- .../issue-activity/comments/comment-card.tsx | 2 +- .../issue-activity/comments/root.tsx | 2 +- .../label/select/label-select.tsx | 2 +- .../issues/issue-detail/links/links.tsx | 2 +- .../issues/issue-detail/main-content.tsx | 4 +- .../issues/issue-detail/module-select.tsx | 2 +- .../issues/issue-detail/parent-select.tsx | 2 +- .../issue-detail/reactions/issue-comment.tsx | 2 +- .../issues/issue-detail/reactions/issue.tsx | 3 +- .../issues/issue-detail/relation-select.tsx | 2 +- web/components/issues/issue-detail/root.tsx | 20 +- .../issues/issue-detail/sidebar.tsx | 26 +- .../issues/issue-detail/subscription.tsx | 2 +- .../calendar/base-calendar-root.tsx | 2 +- .../issue-layouts/calendar/calendar.tsx | 15 +- .../issue-layouts/calendar/day-tile.tsx | 17 +- .../calendar/dropdowns/months-dropdown.tsx | 2 +- .../calendar/dropdowns/options-dropdown.tsx | 2 +- .../issues/issue-layouts/calendar/header.tsx | 2 +- .../issue-layouts/calendar/issue-block.tsx | 14 +- .../issue-layouts/calendar/issue-blocks.tsx | 7 +- .../calendar/quick-add-issue-form.tsx | 2 +- .../calendar/roots/cycle-root.tsx | 2 +- .../calendar/roots/module-root.tsx | 2 +- .../calendar/roots/project-root.tsx | 2 +- .../calendar/roots/project-view-root.tsx | 2 +- .../issue-layouts/calendar/week-days.tsx | 2 +- .../issue-layouts/calendar/week-header.tsx | 2 +- .../empty-states/archived-issues.tsx | 2 +- .../issue-layouts/empty-states/cycle.tsx | 16 +- .../empty-states/draft-issues.tsx | 2 +- .../empty-states/global-view.tsx | 8 +- .../issue-layouts/empty-states/module.tsx | 11 +- .../empty-states/project-issues.tsx | 12 +- .../empty-states/project-view.tsx | 8 +- .../filters/applied-filters/cycle.tsx | 2 +- .../filters/applied-filters/date.tsx | 2 +- .../filters/applied-filters/filters-list.tsx | 20 +- .../filters/applied-filters/label.tsx | 2 +- .../filters/applied-filters/members.tsx | 2 +- .../filters/applied-filters/module.tsx | 2 +- .../filters/applied-filters/priority.tsx | 2 +- .../filters/applied-filters/project.tsx | 2 +- .../applied-filters/roots/archived-issue.tsx | 2 +- .../applied-filters/roots/cycle-root.tsx | 2 +- .../applied-filters/roots/draft-issue.tsx | 2 +- .../roots/global-view-root.tsx | 2 +- .../applied-filters/roots/module-root.tsx | 2 +- .../roots/profile-issues-root.tsx | 2 +- .../applied-filters/roots/project-root.tsx | 2 +- .../roots/project-view-root.tsx | 2 +- .../filters/applied-filters/state-group.tsx | 2 +- .../filters/applied-filters/state.tsx | 2 +- .../filters/header/filters/assignee.tsx | 2 +- .../filters/header/filters/created-by.tsx | 2 +- .../filters/header/filters/cycle.tsx | 6 +- .../header/filters/filters-selection.tsx | 10 +- .../filters/header/filters/mentions.tsx | 2 +- .../filters/header/filters/module.tsx | 8 +- .../filters/header/filters/priority.tsx | 2 +- .../filters/header/filters/start-date.tsx | 2 +- .../filters/header/filters/state-group.tsx | 2 +- .../filters/header/filters/target-date.tsx | 2 +- .../issue-layouts/gantt/base-gantt-root.tsx | 2 +- .../issues/issue-layouts/gantt/blocks.tsx | 14 +- .../issues/issue-layouts/gantt/cycle-root.tsx | 2 +- .../issue-layouts/gantt/module-root.tsx | 2 +- .../issue-layouts/gantt/project-root.tsx | 2 +- .../issue-layouts/gantt/project-view-root.tsx | 2 +- .../gantt/quick-add-issue-form.tsx | 2 +- .../issues/issue-layouts/kanban/block.tsx | 8 +- .../issues/issue-layouts/kanban/default.tsx | 2 +- .../kanban/headers/group-by-card.tsx | 2 +- .../kanban/quick-add-issue-form.tsx | 2 +- .../issue-layouts/kanban/roots/cycle-root.tsx | 2 +- .../kanban/roots/draft-issue-root.tsx | 2 +- .../kanban/roots/module-root.tsx | 2 +- .../kanban/roots/profile-issues-root.tsx | 2 +- .../kanban/roots/project-root.tsx | 2 +- .../kanban/roots/project-view-root.tsx | 2 +- .../issues/issue-layouts/list/block.tsx | 10 +- .../list/headers/group-by-card.tsx | 2 +- .../list/quick-add-issue-form.tsx | 2 +- .../list/roots/archived-issue-root.tsx | 2 +- .../issue-layouts/list/roots/cycle-root.tsx | 2 +- .../list/roots/draft-issue-root.tsx | 2 +- .../issue-layouts/list/roots/module-root.tsx | 2 +- .../list/roots/profile-issues-root.tsx | 2 +- .../issue-layouts/list/roots/project-root.tsx | 2 +- .../list/roots/project-view-root.tsx | 2 +- .../properties/all-properties.tsx | 2 +- .../issue-layouts/properties/labels.tsx | 14 +- .../with-display-properties-HOC.tsx | 2 +- .../roots/all-issue-layout-root.tsx | 12 +- .../roots/archived-issue-layout-root.tsx | 2 +- .../issue-layouts/roots/cycle-layout-root.tsx | 2 +- .../roots/draft-issue-layout-root.tsx | 2 +- .../roots/module-layout-root.tsx | 2 +- .../roots/project-layout-root.tsx | 2 +- .../roots/project-view-layout-root.tsx | 2 +- .../spreadsheet/base-spreadsheet-root.tsx | 2 +- .../spreadsheet/columns/attachment-column.tsx | 2 +- .../spreadsheet/columns/created-on-column.tsx | 2 +- .../spreadsheet/columns/cycle-column.tsx | 2 +- .../spreadsheet/columns/due-date-column.tsx | 2 +- .../spreadsheet/columns/link-column.tsx | 2 +- .../spreadsheet/columns/module-column.tsx | 2 +- .../spreadsheet/columns/start-date-column.tsx | 2 +- .../spreadsheet/columns/sub-issue-column.tsx | 16 +- .../spreadsheet/columns/updated-on-column.tsx | 2 +- .../issue-layouts/spreadsheet/issue-row.tsx | 2 +- .../spreadsheet/quick-add-issue-form.tsx | 2 +- .../spreadsheet/roots/cycle-root.tsx | 2 +- .../spreadsheet/roots/module-root.tsx | 2 +- .../spreadsheet/roots/project-root.tsx | 2 +- .../spreadsheet/roots/project-view-root.tsx | 2 +- .../spreadsheet/spreadsheet-view.tsx | 2 +- web/components/issues/issue-layouts/types.ts | 7 - web/components/issues/issue-modal/form.tsx | 23 +- web/components/issues/issue-modal/modal.tsx | 6 +- .../issues/peek-overview/header.tsx | 7 +- .../issues/peek-overview/issue-detail.tsx | 2 +- .../issues/peek-overview/properties.tsx | 2 +- web/components/issues/peek-overview/root.tsx | 2 +- web/components/issues/peek-overview/view.tsx | 4 +- web/components/issues/select/label.tsx | 2 +- .../issues/sub-issues/issue-list-item.tsx | 2 +- web/components/issues/sub-issues/root.tsx | 2 +- web/components/labels/create-label-modal.tsx | 2 +- .../labels/create-update-label-inline.tsx | 2 +- web/components/labels/delete-label-modal.tsx | 2 +- .../modules/delete-module-modal.tsx | 2 +- .../modules/dropdowns/filters/lead.tsx | 2 +- .../modules/dropdowns/filters/members.tsx | 2 +- web/components/modules/gantt-chart/blocks.tsx | 10 +- .../gantt-chart/modules-list-layout.tsx | 2 +- web/components/modules/modal.tsx | 2 +- .../modules/module-peek-overview.tsx | 2 +- web/components/modules/modules-list-view.tsx | 14 +- .../notifications/notification-header.tsx | 6 +- .../notifications/notification-popover.tsx | 34 +- .../onboarding/create-or-join-workspaces.tsx | 94 + .../onboarding/create-workspace.tsx | 267 + web/components/onboarding/header.tsx | 24 + web/components/onboarding/index.ts | 9 +- web/components/onboarding/invitations.tsx | 246 +- web/components/onboarding/invite-members.tsx | 242 +- web/components/onboarding/join-workspaces.tsx | 85 - .../onboarding/onboarding-sidebar.tsx | 291 -- web/components/onboarding/profile-setup.tsx | 571 ++ web/components/onboarding/step-indicator.tsx | 60 +- .../switch-delete-account-modal.tsx | 37 +- .../switch-or-delete-account-dropdown.tsx | 77 + web/components/onboarding/tour/root.tsx | 26 +- web/components/onboarding/user-details.tsx | 249 - web/components/onboarding/workspace.tsx | 181 - web/components/page-views/index.ts | 1 - web/components/page-views/signin.tsx | 56 - .../page-views/workspace-dashboard.tsx | 19 +- web/components/pages/editor/editor-body.tsx | 2 +- .../pages/editor/header/extra-options.tsx | 8 +- .../pages/editor/header/options-dropdown.tsx | 8 +- .../pages/list/filters/created-by.tsx | 2 +- .../pages/modals/delete-page-modal.tsx | 2 +- .../pages/pages-list-main-content.tsx | 8 +- .../profile/activity/activity-list.tsx | 9 +- .../activity/profile-activity-list.tsx | 15 +- web/components/profile/overview/activity.tsx | 23 +- .../preferences/preferences-mobile-header.tsx | 1 - .../profile/profile-issues-filter.tsx | 2 +- web/components/profile/profile-issues.tsx | 2 +- web/components/profile/sidebar.tsx | 31 +- web/components/project/card-list.tsx | 22 +- .../project/confirm-project-member-remove.tsx | 7 +- .../project/dropdowns/filters/lead.tsx | 2 +- .../project/dropdowns/filters/members.tsx | 2 +- .../project/leave-project-modal.tsx | 2 +- web/components/project/member-list-item.tsx | 8 +- web/components/project/member-list.tsx | 2 +- web/components/project/member-select.tsx | 2 +- .../project-settings-member-defaults.tsx | 2 +- .../project/projects-mobile-header.tsx | 6 +- .../project/publish-project/modal.tsx | 2 +- .../project/send-project-invitation-modal.tsx | 2 +- .../project/settings/features-list.tsx | 10 +- web/components/project/sidebar-list-item.tsx | 15 +- web/components/project/sidebar-list.tsx | 14 +- web/components/states/create-state-modal.tsx | 2 +- .../states/create-update-state-inline.tsx | 2 +- web/components/states/delete-state-modal.tsx | 2 +- .../project-setting-state-list-item.tsx | 2 +- .../states/project-setting-state-list.tsx | 2 +- web/components/views/delete-view-modal.tsx | 2 +- web/components/views/form.tsx | 2 +- web/components/views/modal.tsx | 2 +- web/components/views/views-list.tsx | 6 +- web/components/web-hooks/form/form.tsx | 2 +- web/components/web-hooks/form/secret-key.tsx | 2 +- web/components/web-hooks/webhooks-list.tsx | 2 +- .../ConfirmWorkspaceMemberRemove.tsx | 105 + .../confirm-workspace-member-remove.tsx | 14 +- .../workspace/create-workspace-form.tsx | 2 +- .../workspace/delete-workspace-modal.tsx | 2 +- web/components/workspace/help-section.tsx | 12 +- web/components/workspace/logo.tsx | 19 + .../send-workspace-invitation-modal.tsx | 2 +- .../settings/invitations-list-item.tsx | 2 +- .../workspace/settings/members-list-item.tsx | 15 +- .../workspace/settings/members-list.tsx | 2 +- .../workspace/settings/workspace-details.tsx | 2 +- web/components/workspace/sidebar-dropdown.tsx | 75 +- web/components/workspace/sidebar-menu.tsx | 16 +- .../workspace/sidebar-quick-action.tsx | 24 +- .../views/default-view-list-item.tsx | 2 +- .../workspace/views/delete-view-modal.tsx | 2 +- web/components/workspace/views/form.tsx | 2 +- web/components/workspace/views/modal.tsx | 2 +- .../workspace/views/view-list-item.tsx | 2 +- web/components/workspace/views/views-list.tsx | 10 +- .../workspace-active-cycles-upgrade.tsx | 30 +- web/constants/fetch-keys.ts | 4 +- web/constants/issue.ts | 22 +- web/constants/workspace.ts | 27 +- web/contexts/user-notification-context.tsx | 290 -- web/helpers/authentication.helper.tsx | 295 ++ web/helpers/common.helper.ts | 4 +- web/helpers/event-tracker.helper.ts | 11 - web/helpers/generate-random-string.ts | 11 - web/helpers/password.helper.ts | 16 + web/hooks/store/index.ts | 7 +- web/hooks/store/pages/use-page.ts | 4 +- web/hooks/store/pages/use-project-page.ts | 4 +- web/hooks/store/use-app-router.ts | 10 + web/hooks/store/use-app-theme.ts | 10 + web/hooks/store/use-application.ts | 11 - web/hooks/store/use-calendar-view.ts | 2 +- web/hooks/store/use-command-palette.ts | 11 + web/hooks/store/use-cycle-filter.ts | 2 +- web/hooks/store/use-cycle.ts | 2 +- web/hooks/store/use-dashboard.ts | 2 +- web/hooks/store/use-estimate.ts | 2 +- web/hooks/store/use-event-tracker.ts | 2 +- web/hooks/store/use-global-view.ts | 2 +- web/hooks/store/use-inbox-issues.ts | 2 +- web/hooks/store/use-instance.ts | 10 + web/hooks/store/use-issue-detail.ts | 2 +- web/hooks/store/use-issues.ts | 2 +- web/hooks/store/use-kanban-view.ts | 2 +- web/hooks/store/use-label.ts | 2 +- web/hooks/store/use-member.ts | 2 +- web/hooks/store/use-module-filter.ts | 2 +- web/hooks/store/use-module.ts | 2 +- web/hooks/store/use-project-filter.ts | 2 +- web/hooks/store/use-project-inbox.ts | 3 +- web/hooks/store/use-project-publish.ts | 2 +- web/hooks/store/use-project-state.ts | 2 +- web/hooks/store/use-project-view.ts | 2 +- web/hooks/store/use-project.ts | 2 +- web/hooks/store/use-webhook.ts | 2 +- web/hooks/store/use-workspace.ts | 2 +- web/hooks/store/user/index.ts | 3 + web/hooks/store/user/user-user-profile.ts | 11 + web/hooks/store/user/user-user-settings.ts | 11 + .../store/{use-user.ts => user/user-user.ts} | 6 +- web/hooks/use-comment-reaction.tsx | 8 +- .../use-issue-notification-subscription.tsx | 7 +- web/hooks/use-issues-actions.tsx | 38 +- web/hooks/use-sign-in-redirection.ts | 95 - web/hooks/use-user-auth.tsx | 115 - web/hooks/use-user-notifications.tsx | 2 +- web/hooks/use-user.tsx | 53 - web/layouts/admin-layout/header.tsx | 40 - web/layouts/admin-layout/index.ts | 3 - web/layouts/admin-layout/layout.tsx | 44 - web/layouts/admin-layout/sidebar.tsx | 29 - web/layouts/app-layout/layout.tsx | 50 +- web/layouts/app-layout/sidebar.tsx | 23 +- web/layouts/auth-layout/admin-wrapper.tsx | 31 - web/layouts/auth-layout/index.ts | 2 - web/layouts/auth-layout/project-wrapper.tsx | 9 +- web/layouts/auth-layout/user-wrapper.tsx | 62 - web/layouts/auth-layout/workspace-wrapper.tsx | 46 +- web/layouts/instance-layout/index.tsx | 45 - .../settings-layout/profile/layout.tsx | 7 +- .../profile/preferences/layout.tsx | 8 +- .../settings-layout/profile/sidebar.tsx | 32 +- .../settings-layout/project/layout.tsx | 2 +- web/layouts/user-profile-layout/layout.tsx | 2 +- web/lib/app-provider.tsx | 22 +- web/lib/posthog-provider.tsx | 12 +- web/{contexts => lib}/store-context.tsx | 12 +- web/lib/wrappers/authentication-wrapper.tsx | 130 + web/lib/wrappers/crisp-wrapper.tsx | 2 +- web/lib/wrappers/index.ts | 2 + web/lib/wrappers/instance-wrapper.tsx | 39 + web/lib/wrappers/store-wrapper.tsx | 51 +- web/next-auth.d.ts | 13 + web/next.config.js | 20 +- web/package.json | 10 +- web/pages/[workspaceSlug]/analytics.tsx | 20 +- .../profile/[userId]/activity.tsx | 8 +- .../projects/[projectId]/cycles/index.tsx | 2 +- .../projects/[projectId]/issues/[issueId].tsx | 15 +- .../projects/[projectId]/pages/index.tsx | 6 +- .../[projectId]/settings/automations.tsx | 2 +- .../[projectId]/settings/estimates.tsx | 2 +- .../projects/[projectId]/settings/index.tsx | 2 +- web/pages/[workspaceSlug]/projects/index.tsx | 8 +- .../[workspaceSlug]/settings/api-tokens.tsx | 2 +- .../[workspaceSlug]/settings/billing.tsx | 2 +- .../[workspaceSlug]/settings/exports.tsx | 2 +- .../[workspaceSlug]/settings/imports.tsx | 58 + .../[workspaceSlug]/settings/integrations.tsx | 82 + .../[workspaceSlug]/settings/members.tsx | 2 +- .../settings/webhooks/[webhookId].tsx | 2 +- .../settings/webhooks/index.tsx | 2 +- web/pages/_app.tsx | 5 +- web/pages/_error.tsx | 3 +- web/pages/accounts/forgot-password.tsx | 129 +- web/pages/accounts/reset-password.tsx | 295 +- web/pages/accounts/set-password.tsx | 219 + web/pages/accounts/sign-in.tsx | 77 + web/pages/accounts/sign-up.tsx | 57 - web/pages/create-workspace.tsx | 14 +- web/pages/god-mode/ai.tsx | 68 - web/pages/god-mode/authorization.tsx | 185 - web/pages/god-mode/email.tsx | 58 - web/pages/god-mode/image.tsx | 54 - web/pages/god-mode/index.tsx | 56 - web/pages/index.tsx | 74 +- web/pages/invitations/index.tsx | 40 +- web/pages/onboarding/index.tsx | 295 +- web/pages/profile/activity.tsx | 18 +- web/pages/profile/change-password.tsx | 28 +- web/pages/profile/index.tsx | 35 +- web/pages/profile/preferences/theme.tsx | 23 +- .../index.tsx => workspace-invitations.tsx} | 29 +- web/public/auth/background-pattern-dark.svg | 68 + web/public/auth/background-pattern.svg | 68 + web/public/logos/google-logo.svg | 1 + .../onboarding/create-join-workspace-dark.svg | 278 + .../onboarding/create-join-workspace.svg | 331 ++ web/public/onboarding/invite-members-dark.svg | 224 + .../onboarding/invite-members-light.svg | 240 + web/public/onboarding/onboarding-issues.webp | Bin 57912 -> 0 bytes web/public/onboarding/profile-setup-dark.svg | 448 ++ web/public/onboarding/profile-setup-light.svg | 407 ++ web/public/onboarding/user-dark.svg | 14 - web/public/onboarding/user-light.svg | 14 - .../onboarding/user-personalization-dark.svg | 214 + .../onboarding/user-personalization-light.svg | 180 + web/public/plane-takeoff.png | Bin 0 -> 47818 bytes web/services/ai.service.ts | 9 +- web/services/api.service.ts | 105 +- web/services/app_config.service.ts | 9 +- web/services/auth.service.ts | 153 +- web/services/file.service.ts | 3 - web/services/instance.service.ts | 53 +- .../issue/issue_attachment.service.ts | 9 +- web/services/project/project.service.ts | 12 +- web/services/user.service.ts | 31 +- web/store/application/app-config.store.ts | 40 - web/store/application/index.ts | 32 - web/store/application/instance.store.ts | 126 - .../command-palette.store.ts | 9 +- web/store/cycle.store.ts | 14 +- web/store/cycle_filter.store.ts | 8 +- web/store/dashboard.store.ts | 12 +- web/store/estimate.store.ts | 13 +- web/store/inbox/project-inbox.store.ts | 6 +- web/store/instance.store.ts | 67 + web/store/issue/root.store.ts | 16 +- web/store/label.store.ts | 19 +- web/store/member/project-member.store.ts | 22 +- web/store/member/workspace-member.store.ts | 24 +- web/store/module.store.ts | 4 +- web/store/module_filter.store.ts | 8 +- web/store/pages/page.store.ts | 24 +- web/store/pages/project-page.store.ts | 14 +- web/store/project-view.store.ts | 6 +- web/store/project/project.store.ts | 4 +- web/store/project/project_filter.store.ts | 8 +- web/store/root.store.ts | 49 +- web/store/{application => }/router.store.ts | 2 +- web/store/state.store.ts | 2 +- web/store/{application => }/theme.store.ts | 43 +- web/store/user/account.store.ts | 45 + web/store/user/index.ts | 347 +- web/store/user/profile.store.ts | 216 + web/store/user/settings.store.ts | 78 + web/store/user/user-membership.store.ts | 2 +- web/store/workspace/index.ts | 4 +- web/store/workspace/webhook.store.ts | 4 +- web/tsconfig.json | 15 +- yarn.lock | 4576 +++++++++-------- 903 files changed, 25736 insertions(+), 16041 deletions(-) create mode 100644 admin/.env.example create mode 100644 admin/.eslintrc.js create mode 100644 admin/.prettierignore create mode 100644 admin/.prettierrc create mode 100644 admin/Dockerfile.admin create mode 100644 admin/app/ai/components/ai-config-form.tsx create mode 100644 admin/app/ai/components/index.ts create mode 100644 admin/app/ai/layout.tsx create mode 100644 admin/app/ai/page.tsx create mode 100644 admin/app/authentication/components/common/authentication-method-card.tsx create mode 100644 admin/app/authentication/components/common/index.ts create mode 100644 admin/app/authentication/components/email-config-switch.tsx create mode 100644 admin/app/authentication/components/index.ts create mode 100644 admin/app/authentication/components/password-config-switch.tsx create mode 100644 admin/app/authentication/github/components/github-config-form.tsx create mode 100644 admin/app/authentication/github/components/index.ts create mode 100644 admin/app/authentication/github/components/root.tsx create mode 100644 admin/app/authentication/github/page.tsx create mode 100644 admin/app/authentication/google/components/google-config-form.tsx create mode 100644 admin/app/authentication/google/components/index.ts create mode 100644 admin/app/authentication/google/components/root.tsx create mode 100644 admin/app/authentication/google/page.tsx create mode 100644 admin/app/authentication/layout.tsx create mode 100644 admin/app/authentication/page.tsx create mode 100644 admin/app/email/components/email-config-form.tsx create mode 100644 admin/app/email/components/index.ts create mode 100644 admin/app/email/components/test-email-modal.tsx create mode 100644 admin/app/email/layout.tsx create mode 100644 admin/app/email/page.tsx create mode 100644 admin/app/general/components/general-config-form.tsx create mode 100644 admin/app/general/components/index.ts create mode 100644 admin/app/general/layout.tsx create mode 100644 admin/app/general/page.tsx create mode 100644 admin/app/globals.css create mode 100644 admin/app/image/components/image-config-form.tsx create mode 100644 admin/app/image/components/index.ts create mode 100644 admin/app/image/layout.tsx create mode 100644 admin/app/image/page.tsx create mode 100644 admin/app/layout.tsx rename {web/components/instance/setup-form => admin/app/login/components}/index.ts (57%) create mode 100644 admin/app/login/components/sign-in-form.tsx create mode 100644 admin/app/login/layout.tsx create mode 100644 admin/app/login/page.tsx create mode 100644 admin/app/page.tsx create mode 100644 admin/app/setup/components/index.ts create mode 100644 admin/app/setup/components/sign-up-form.tsx create mode 100644 admin/app/setup/layout.tsx create mode 100644 admin/app/setup/page.tsx rename {web/components/instance => admin/components/admin-sidebar}/help-section.tsx (54%) create mode 100644 admin/components/admin-sidebar/index.ts create mode 100644 admin/components/admin-sidebar/root.tsx create mode 100644 admin/components/admin-sidebar/sidebar-dropdown.tsx create mode 100644 admin/components/admin-sidebar/sidebar-menu-hamburger-toogle.tsx create mode 100644 admin/components/admin-sidebar/sidebar-menu.tsx create mode 100644 admin/components/auth-header.tsx create mode 100644 admin/components/common/banner.tsx create mode 100644 admin/components/common/breadcrumb-link.tsx create mode 100644 admin/components/common/confirm-discard-modal.tsx create mode 100644 admin/components/common/controller-input.tsx create mode 100644 admin/components/common/copy-field.tsx create mode 100644 admin/components/common/index.ts create mode 100644 admin/components/common/password-strength-meter.tsx create mode 100644 admin/components/core/index.ts create mode 100644 admin/components/core/page-header.tsx create mode 100644 admin/components/instance/index.ts create mode 100644 admin/components/instance/instance-not-ready.tsx create mode 100644 admin/components/new-user-popup.tsx create mode 100644 admin/constants/seo.ts create mode 100644 admin/constants/swr-config.ts create mode 100644 admin/helpers/authentication.helper.tsx create mode 100644 admin/helpers/common.helper.ts create mode 100644 admin/helpers/index.ts create mode 100644 admin/helpers/instance.helper.ts create mode 100644 admin/helpers/password.helper.ts create mode 100644 admin/helpers/user.helper.ts create mode 100644 admin/hooks/index.ts create mode 100644 admin/hooks/store/use-instance.tsx create mode 100644 admin/hooks/store/use-theme.tsx create mode 100644 admin/hooks/store/use-user.tsx create mode 100644 admin/hooks/use-outside-click-detector.tsx create mode 100644 admin/layouts/admin-layout.tsx create mode 100644 admin/layouts/default-layout.tsx create mode 100644 admin/layouts/index.ts create mode 100644 admin/lib/store-context.tsx create mode 100644 admin/lib/wrappers/app-wrapper.tsx create mode 100644 admin/lib/wrappers/auth-wrapper.tsx create mode 100644 admin/lib/wrappers/index.ts create mode 100644 admin/lib/wrappers/instance-wrapper.tsx create mode 100644 admin/next-env.d.ts create mode 100644 admin/next.config.js create mode 100644 admin/package.json create mode 100644 admin/postcss.config.js create mode 100644 admin/public/auth/background-pattern-dark.svg create mode 100644 admin/public/auth/background-pattern.svg create mode 100644 admin/public/favicon/android-chrome-192x192.png create mode 100644 admin/public/favicon/android-chrome-512x512.png create mode 100644 admin/public/favicon/apple-touch-icon.png create mode 100644 admin/public/favicon/favicon-16x16.png create mode 100644 admin/public/favicon/favicon-32x32.png create mode 100644 admin/public/favicon/favicon.ico create mode 100644 admin/public/favicon/site.webmanifest create mode 100644 admin/public/images/plane-takeoff.png create mode 100644 admin/public/logos/github-black.png create mode 100644 admin/public/logos/github-white.png create mode 100644 admin/public/logos/google-logo.svg create mode 100644 admin/public/logos/takeoff-icon-dark.svg create mode 100644 admin/public/logos/takeoff-icon-light.svg create mode 100644 admin/public/plane-logos/blue-without-text.png create mode 100644 admin/public/site.webmanifest.json create mode 100644 admin/services/api.service.ts create mode 100644 admin/services/auth.service.ts create mode 100644 admin/services/index.ts create mode 100644 admin/services/instance.service.ts create mode 100644 admin/services/user.service.ts create mode 100644 admin/store/instance.store.ts create mode 100644 admin/store/root-store.ts create mode 100644 admin/store/theme.store.ts create mode 100644 admin/store/user.store.ts create mode 100644 admin/tailwind.config.js create mode 100644 admin/tsconfig.json delete mode 100644 apiserver/plane/app/urls/authentication.py delete mode 100644 apiserver/plane/app/urls/config.py delete mode 100644 apiserver/plane/app/views/auth_extended.py delete mode 100644 apiserver/plane/app/views/authentication.py delete mode 100644 apiserver/plane/app/views/config.py delete mode 100644 apiserver/plane/app/views/oauth.py create mode 100644 apiserver/plane/authentication/__init__.py create mode 100644 apiserver/plane/authentication/adapter/__init__.py create mode 100644 apiserver/plane/authentication/adapter/base.py create mode 100644 apiserver/plane/authentication/adapter/credential.py create mode 100644 apiserver/plane/authentication/adapter/error.py create mode 100644 apiserver/plane/authentication/adapter/exception.py create mode 100644 apiserver/plane/authentication/adapter/oauth.py create mode 100644 apiserver/plane/authentication/apps.py create mode 100644 apiserver/plane/authentication/middleware/__init__.py create mode 100644 apiserver/plane/authentication/middleware/session.py create mode 100644 apiserver/plane/authentication/provider/__init__.py create mode 100644 apiserver/plane/authentication/provider/credentials/__init__.py create mode 100644 apiserver/plane/authentication/provider/credentials/email.py create mode 100644 apiserver/plane/authentication/provider/credentials/magic_code.py create mode 100644 apiserver/plane/authentication/provider/oauth/__init__.py create mode 100644 apiserver/plane/authentication/provider/oauth/github.py create mode 100644 apiserver/plane/authentication/provider/oauth/google.py create mode 100644 apiserver/plane/authentication/session.py create mode 100644 apiserver/plane/authentication/urls.py create mode 100644 apiserver/plane/authentication/utils/host.py create mode 100644 apiserver/plane/authentication/utils/login.py create mode 100644 apiserver/plane/authentication/utils/redirection_path.py create mode 100644 apiserver/plane/authentication/utils/workspace_project_join.py create mode 100644 apiserver/plane/authentication/views/__init__.py create mode 100644 apiserver/plane/authentication/views/app/check.py create mode 100644 apiserver/plane/authentication/views/app/email.py create mode 100644 apiserver/plane/authentication/views/app/github.py create mode 100644 apiserver/plane/authentication/views/app/google.py create mode 100644 apiserver/plane/authentication/views/app/magic.py create mode 100644 apiserver/plane/authentication/views/app/signout.py create mode 100644 apiserver/plane/authentication/views/common.py create mode 100644 apiserver/plane/authentication/views/space/check.py create mode 100644 apiserver/plane/authentication/views/space/email.py create mode 100644 apiserver/plane/authentication/views/space/github.py create mode 100644 apiserver/plane/authentication/views/space/google.py create mode 100644 apiserver/plane/authentication/views/space/magic.py create mode 100644 apiserver/plane/authentication/views/space/signout.py create mode 100644 apiserver/plane/db/migrations/0065_auto_20240415_0937.py create mode 100644 apiserver/plane/db/models/session.py create mode 100644 apiserver/plane/license/api/serializers/admin.py create mode 100644 apiserver/plane/license/api/serializers/base.py create mode 100644 apiserver/plane/license/api/serializers/configuration.py create mode 100644 apiserver/plane/license/api/views/admin.py create mode 100644 apiserver/plane/license/api/views/base.py create mode 100644 apiserver/plane/license/api/views/configuration.py create mode 100644 nginx/.prettierignore delete mode 100644 packages/types/src/app.d.ts create mode 100644 packages/types/src/current-user/accounts.d.ts create mode 100644 packages/types/src/current-user/index.ts create mode 100644 packages/types/src/current-user/profile.d.ts create mode 100644 packages/types/src/current-user/user.d.ts delete mode 100644 packages/types/src/instance.d.ts create mode 100644 packages/types/src/instance/ai.d.ts create mode 100644 packages/types/src/instance/auth.d.ts create mode 100644 packages/types/src/instance/base.d.ts create mode 100644 packages/types/src/instance/email.d.ts create mode 100644 packages/types/src/instance/image.d.ts create mode 100644 packages/types/src/instance/index.d.ts create mode 100644 space/components/accounts/auth-forms/email.tsx rename {web/components/account/sign-in-forms => space/components/accounts/auth-forms}/forgot-password-popover.tsx (100%) rename {web/components/account/sign-in-forms => space/components/accounts/auth-forms}/index.ts (78%) create mode 100644 space/components/accounts/auth-forms/password.tsx create mode 100644 space/components/accounts/auth-forms/root.tsx create mode 100644 space/components/accounts/auth-forms/unique-code.tsx delete mode 100644 space/components/accounts/github-sign-in.tsx delete mode 100644 space/components/accounts/google-sign-in.tsx create mode 100644 space/components/accounts/oauth/github-button.tsx create mode 100644 space/components/accounts/oauth/google-button.tsx create mode 100644 space/components/accounts/oauth/index.ts create mode 100644 space/components/accounts/oauth/oauth-options.tsx create mode 100644 space/components/accounts/password-strength-meter.tsx delete mode 100644 space/components/accounts/sign-in-forms/create-password.tsx delete mode 100644 space/components/accounts/sign-in-forms/email-form.tsx delete mode 100644 space/components/accounts/sign-in-forms/index.ts delete mode 100644 space/components/accounts/sign-in-forms/o-auth-options.tsx delete mode 100644 space/components/accounts/sign-in-forms/optional-set-password.tsx delete mode 100644 space/components/accounts/sign-in-forms/password.tsx delete mode 100644 space/components/accounts/sign-in-forms/root.tsx delete mode 100644 space/components/accounts/sign-in-forms/self-hosted-sign-in.tsx delete mode 100644 space/components/accounts/sign-in-forms/set-password-link.tsx delete mode 100644 space/components/accounts/sign-in-forms/unique-code.tsx create mode 100644 space/components/accounts/terms-and-conditions.tsx create mode 100644 space/components/accounts/user-image-upload-modal.tsx create mode 100644 space/components/instance/index.ts create mode 100644 space/components/instance/not-ready-view.tsx create mode 100644 space/components/views/auth.tsx delete mode 100644 space/components/views/login.tsx create mode 100644 space/helpers/authentication.helper.tsx create mode 100644 space/helpers/password.helper.ts create mode 100644 space/hooks/store/index.ts create mode 100644 space/hooks/store/use-instance.ts create mode 100644 space/hooks/store/user-mobx-provider.ts create mode 100644 space/hooks/store/user/index.ts create mode 100644 space/hooks/store/user/use-user-profile.ts create mode 100644 space/hooks/store/user/use-user.ts delete mode 100644 space/hooks/use-sign-in-redirection.tsx delete mode 100644 space/lib/mobx/store-init.tsx delete mode 100644 space/lib/mobx/store-provider.tsx create mode 100644 space/lib/store-context.tsx create mode 100644 space/lib/wrappers/auth-wrapper.tsx create mode 100644 space/lib/wrappers/index.ts create mode 100644 space/lib/wrappers/instance-wrapper.tsx create mode 100644 space/pages/accounts/forgot-password.tsx delete mode 100644 space/pages/accounts/password.tsx create mode 100644 space/pages/accounts/reset-password.tsx create mode 100644 space/public/auth/background-pattern-dark.svg create mode 100644 space/public/auth/background-pattern.svg create mode 100644 space/public/instance/plane-instance-not-ready.webp create mode 100644 space/public/logos/github-black.png delete mode 100644 space/public/logos/github-black.svg create mode 100644 space/public/logos/github-dark.svg create mode 100644 space/public/logos/google-logo.svg create mode 100644 space/public/onboarding/profile-setup-dark.svg create mode 100644 space/public/onboarding/profile-setup.svg delete mode 100644 space/services/app-config.service.ts create mode 100644 space/services/instance.service.ts create mode 100644 space/store/instance.store.ts rename space/store/{root.ts => root.store.ts} (50%) delete mode 100644 space/store/user.ts create mode 100644 space/store/user/index.store.ts create mode 100644 space/store/user/profile.store.ts rename space/types/{app.ts => app.d.ts} (100%) rename space/types/{auth.ts => auth.d.ts} (100%) rename space/types/{issue.ts => issue.d.ts} (100%) rename space/types/{project.ts => project.d.ts} (100%) rename space/types/{theme.ts => theme.d.ts} (100%) rename space/types/{user.ts => user.d.ts} (100%) create mode 100644 web/components/account/auth-forms/auth-banner.tsx create mode 100644 web/components/account/auth-forms/auth-header.tsx create mode 100644 web/components/account/auth-forms/auth-root.tsx create mode 100644 web/components/account/auth-forms/email.tsx create mode 100644 web/components/account/auth-forms/forgot-password-popover.tsx create mode 100644 web/components/account/auth-forms/index.ts create mode 100644 web/components/account/auth-forms/password.tsx create mode 100644 web/components/account/auth-forms/unique-code.tsx delete mode 100644 web/components/account/o-auth/github-sign-in.tsx delete mode 100644 web/components/account/o-auth/google-sign-in.tsx delete mode 100644 web/components/account/o-auth/index.ts delete mode 100644 web/components/account/o-auth/o-auth-options.tsx create mode 100644 web/components/account/oauth/github-button.tsx create mode 100644 web/components/account/oauth/google-button.tsx create mode 100644 web/components/account/oauth/index.ts create mode 100644 web/components/account/oauth/oauth-options.tsx create mode 100644 web/components/account/password-strength-meter.tsx delete mode 100644 web/components/account/sign-in-forms/email.tsx delete mode 100644 web/components/account/sign-in-forms/optional-set-password.tsx delete mode 100644 web/components/account/sign-in-forms/password.tsx delete mode 100644 web/components/account/sign-in-forms/root.tsx delete mode 100644 web/components/account/sign-in-forms/unique-code.tsx delete mode 100644 web/components/account/sign-up-forms/email.tsx delete mode 100644 web/components/account/sign-up-forms/index.ts delete mode 100644 web/components/account/sign-up-forms/optional-set-password.tsx delete mode 100644 web/components/account/sign-up-forms/password.tsx delete mode 100644 web/components/account/sign-up-forms/root.tsx delete mode 100644 web/components/account/sign-up-forms/unique-code.tsx delete mode 100644 web/components/breadcrumbs/index.tsx delete mode 100644 web/components/instance/ai-form.tsx delete mode 100644 web/components/instance/email-form.tsx delete mode 100644 web/components/instance/general-form.tsx delete mode 100644 web/components/instance/github-config-form.tsx delete mode 100644 web/components/instance/google-config-form.tsx delete mode 100644 web/components/instance/image-config-form.tsx delete mode 100644 web/components/instance/instance-admin-restriction.tsx delete mode 100644 web/components/instance/setup-done-view.tsx delete mode 100644 web/components/instance/setup-form/root.tsx delete mode 100644 web/components/instance/setup-form/sign-in-form.tsx delete mode 100644 web/components/instance/setup-view.tsx delete mode 100644 web/components/instance/sidebar-dropdown.tsx delete mode 100644 web/components/instance/sidebar-menu.tsx delete mode 100644 web/components/issues/issue-layouts/types.ts create mode 100644 web/components/onboarding/create-or-join-workspaces.tsx create mode 100644 web/components/onboarding/create-workspace.tsx create mode 100644 web/components/onboarding/header.tsx delete mode 100644 web/components/onboarding/join-workspaces.tsx delete mode 100644 web/components/onboarding/onboarding-sidebar.tsx create mode 100644 web/components/onboarding/profile-setup.tsx create mode 100644 web/components/onboarding/switch-or-delete-account-dropdown.tsx delete mode 100644 web/components/onboarding/user-details.tsx delete mode 100644 web/components/onboarding/workspace.tsx delete mode 100644 web/components/page-views/signin.tsx create mode 100644 web/components/workspace/ConfirmWorkspaceMemberRemove.tsx create mode 100644 web/components/workspace/logo.tsx delete mode 100644 web/contexts/user-notification-context.tsx create mode 100644 web/helpers/authentication.helper.tsx delete mode 100644 web/helpers/event-tracker.helper.ts delete mode 100644 web/helpers/generate-random-string.ts create mode 100644 web/helpers/password.helper.ts create mode 100644 web/hooks/store/use-app-router.ts create mode 100644 web/hooks/store/use-app-theme.ts delete mode 100644 web/hooks/store/use-application.ts create mode 100644 web/hooks/store/use-command-palette.ts create mode 100644 web/hooks/store/use-instance.ts create mode 100644 web/hooks/store/user/index.ts create mode 100644 web/hooks/store/user/user-user-profile.ts create mode 100644 web/hooks/store/user/user-user-settings.ts rename web/hooks/store/{use-user.ts => user/user-user.ts} (59%) delete mode 100644 web/hooks/use-sign-in-redirection.ts delete mode 100644 web/hooks/use-user-auth.tsx delete mode 100644 web/hooks/use-user.tsx delete mode 100644 web/layouts/admin-layout/header.tsx delete mode 100644 web/layouts/admin-layout/index.ts delete mode 100644 web/layouts/admin-layout/layout.tsx delete mode 100644 web/layouts/admin-layout/sidebar.tsx delete mode 100644 web/layouts/auth-layout/admin-wrapper.tsx delete mode 100644 web/layouts/auth-layout/user-wrapper.tsx delete mode 100644 web/layouts/instance-layout/index.tsx rename web/{contexts => lib}/store-context.tsx (50%) create mode 100644 web/lib/wrappers/authentication-wrapper.tsx create mode 100644 web/lib/wrappers/index.ts create mode 100644 web/lib/wrappers/instance-wrapper.tsx create mode 100644 web/next-auth.d.ts create mode 100644 web/pages/[workspaceSlug]/settings/imports.tsx create mode 100644 web/pages/[workspaceSlug]/settings/integrations.tsx create mode 100644 web/pages/accounts/set-password.tsx create mode 100644 web/pages/accounts/sign-in.tsx delete mode 100644 web/pages/accounts/sign-up.tsx delete mode 100644 web/pages/god-mode/ai.tsx delete mode 100644 web/pages/god-mode/authorization.tsx delete mode 100644 web/pages/god-mode/email.tsx delete mode 100644 web/pages/god-mode/image.tsx delete mode 100644 web/pages/god-mode/index.tsx rename web/pages/{workspace-invitations/index.tsx => workspace-invitations.tsx} (90%) create mode 100644 web/public/auth/background-pattern-dark.svg create mode 100644 web/public/auth/background-pattern.svg create mode 100644 web/public/logos/google-logo.svg create mode 100644 web/public/onboarding/create-join-workspace-dark.svg create mode 100644 web/public/onboarding/create-join-workspace.svg create mode 100644 web/public/onboarding/invite-members-dark.svg create mode 100644 web/public/onboarding/invite-members-light.svg delete mode 100644 web/public/onboarding/onboarding-issues.webp create mode 100644 web/public/onboarding/profile-setup-dark.svg create mode 100644 web/public/onboarding/profile-setup-light.svg delete mode 100644 web/public/onboarding/user-dark.svg delete mode 100644 web/public/onboarding/user-light.svg create mode 100644 web/public/onboarding/user-personalization-dark.svg create mode 100644 web/public/onboarding/user-personalization-light.svg create mode 100644 web/public/plane-takeoff.png delete mode 100644 web/store/application/app-config.store.ts delete mode 100644 web/store/application/index.ts delete mode 100644 web/store/application/instance.store.ts rename web/store/{application => }/command-palette.store.ts (96%) create mode 100644 web/store/instance.store.ts rename web/store/{application => }/router.store.ts (100%) rename web/store/{application => }/theme.store.ts (68%) create mode 100644 web/store/user/account.store.ts create mode 100644 web/store/user/profile.store.ts create mode 100644 web/store/user/settings.store.ts diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml index 306f92957..1f14f15aa 100644 --- a/.github/workflows/build-branch.yml +++ b/.github/workflows/build-branch.yml @@ -23,6 +23,7 @@ jobs: 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_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 }} @@ -67,6 +68,13 @@ jobs: - 'yarn.lock' - 'tsconfig.json' - 'turbo.json' + admin: + - admin/** + - packages/** + - 'package.json' + - 'yarn.lock' + - 'tsconfig.json' + - 'turbo.json' backend: - apiserver/** proxy: @@ -124,6 +132,58 @@ jobs: DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} + branch_build_push_admin: + if: ${{ needs.branch_build_setup.outputs.build_admin== 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} + runs-on: ubuntu-20.04 + needs: [branch_build_setup] + env: + ADMIN_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-admin:${{ needs.branch_build_setup.outputs.gh_branch_name }} + TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} + BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} + BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} + BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }} + BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }} + steps: + - name: Set Admin Docker Tag + run: | + if [ "${{ github.event_name }}" == "release" ]; then + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-admin:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-admin:${{ github.event.release.tag_name }} + elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-admin:latest + else + TAG=${{ env.ADMIN_TAG }} + fi + echo "ADMIN_TAG=${TAG}" >> $GITHUB_ENV + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver: ${{ env.BUILDX_DRIVER }} + version: ${{ env.BUILDX_VERSION }} + endpoint: ${{ env.BUILDX_ENDPOINT }} + + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Build and Push Frontend to Docker Container Registry + uses: docker/build-push-action@v5.1.0 + with: + context: . + file: ./admin/Dockerfile.admin + platforms: ${{ env.BUILDX_PLATFORMS }} + tags: ${{ env.ADMIN_TAG }} + push: true + env: + DOCKER_BUILDKIT: 1 + DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} + branch_build_push_space: if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} runs-on: ubuntu-20.04 diff --git a/.github/workflows/feature-deployment.yml b/.github/workflows/feature-deployment.yml index c5eec3cd3..a0a9dc7f1 100644 --- a/.github/workflows/feature-deployment.yml +++ b/.github/workflows/feature-deployment.yml @@ -13,10 +13,16 @@ on: description: 'Build Space' type: boolean default: false + admin-build: + required: false + description: 'Build Admin' + type: boolean + default: false env: BUILD_WEB: ${{ github.event.inputs.web-build }} BUILD_SPACE: ${{ github.event.inputs.space-build }} + BUILD_ADMIN: ${{ github.event.inputs.admin-build }} jobs: setup-feature-build: @@ -27,9 +33,11 @@ jobs: run: | echo "BUILD_WEB=$BUILD_WEB" echo "BUILD_SPACE=$BUILD_SPACE" + echo "BUILD_ADMIN=$BUILD_ADMIN" outputs: web-build: ${{ env.BUILD_WEB}} space-build: ${{env.BUILD_SPACE}} + admin-build: ${{env.BUILD_ADMIN}} feature-build-web: if: ${{ needs.setup-feature-build.outputs.web-build == 'true' }} @@ -117,9 +125,54 @@ jobs: FILE_EXPIRY=$(date -u -d "+2 days" +"%Y-%m-%dT%H:%M:%SZ") aws s3 cp $TAR_NAME s3://${{ env.AWS_BUCKET }}/${{github.sha}}/$TAR_NAME --expires $FILE_EXPIRY + feature-build-admin: + if: ${{ needs.setup-feature-build.outputs.admin-build == 'true' }} + needs: setup-feature-build + name: Feature Build Admin + runs-on: ubuntu-latest + env: + AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }} + AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }} + NEXT_PUBLIC_DEPLOY_WITH_NGINX: 1 + NEXT_PUBLIC_API_BASE_URL: ${{ vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL }} + outputs: + do-build: ${{ needs.setup-feature-build.outputs.admin-build }} + s3-url: ${{ steps.build-admin.outputs.S3_PRESIGNED_URL }} + steps: + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + - name: Install AWS cli + run: | + sudo apt-get update + sudo apt-get install -y python3-pip + pip3 install awscli + - name: Checkout + uses: actions/checkout@v4 + with: + path: plane + - name: Install Dependencies + run: | + cd $GITHUB_WORKSPACE/plane + yarn install + - name: Build Admin + id: build-admin + run: | + cd $GITHUB_WORKSPACE/plane + yarn build --filter=admin + cd $GITHUB_WORKSPACE + + TAR_NAME="admin.tar.gz" + tar -czf $TAR_NAME ./plane + + FILE_EXPIRY=$(date -u -d "+2 days" +"%Y-%m-%dT%H:%M:%SZ") + aws s3 cp $TAR_NAME s3://${{ env.AWS_BUCKET }}/${{github.sha}}/$TAR_NAME --expires $FILE_EXPIRY + feature-deploy: - if: ${{ always() && (needs.setup-feature-build.outputs.web-build == 'true' || needs.setup-feature-build.outputs.space-build == 'true') }} - needs: [feature-build-web, feature-build-space] + if: ${{ always() && (needs.setup-feature-build.outputs.web-build == 'true' || needs.setup-feature-build.outputs.space-build == 'true' || needs.setup-feature-build.outputs.admin-build == 'true') }} + needs: [setup-feature-build, feature-build-web, feature-build-space, feature-build-admin] name: Feature Deploy runs-on: ubuntu-latest env: @@ -164,7 +217,12 @@ jobs: SPACE_S3_URL=$(aws s3 presign s3://${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}/${{github.sha}}/space.tar.gz --expires-in 3600) fi - if [ ${{ env.BUILD_WEB }} == true ] || [ ${{ env.BUILD_SPACE }} == true ]; then + ADMIN_S3_URL="" + if [ ${{ env.BUILD_ADMIN }} == true ]; then + ADMIN_S3_URL=$(aws s3 presign s3://${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}/${{github.sha}}/admin.tar.gz --expires-in 3600) + fi + + if [ ${{ env.BUILD_WEB }} == true ] || [ ${{ env.BUILD_SPACE }} == true ] || [ ${{ env.BUILD_ADMIN }} == true ]; then helm --kube-insecure-skip-tls-verify repo add feature-preview ${{ vars.FEATURE_PREVIEW_HELM_CHART_URL }} @@ -181,6 +239,9 @@ jobs: --set space.image=${{vars.FEATURE_PREVIEW_DOCKER_BASE}} \ --set space.enabled=${{ env.BUILD_SPACE || false }} \ --set space.artifact_url=$SPACE_S3_URL \ + --set admin.image=${{vars.FEATURE_PREVIEW_DOCKER_BASE}} \ + --set admin.enabled=${{ env.BUILD_ADMIN || false }} \ + --set admin.artifact_url=$ADMIN_S3_URL \ --set shared_config.deploy_script_url=$DEPLOY_SCRIPT_URL \ --set shared_config.api_base_url=${{vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL}} \ --output json \ diff --git a/.gitignore b/.gitignore index 3989f4356..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/admin/.env.example b/admin/.env.example new file mode 100644 index 000000000..fbd4ad4f9 --- /dev/null +++ b/admin/.env.example @@ -0,0 +1,2 @@ +NEXT_PUBLIC_APP_URL= +NEXT_PUBLIC_API_BASE_URL= \ No newline at end of file diff --git a/admin/.eslintrc.js b/admin/.eslintrc.js new file mode 100644 index 000000000..2278de30f --- /dev/null +++ b/admin/.eslintrc.js @@ -0,0 +1,14 @@ +module.exports = { + root: true, + extends: ["custom"], + parser: "@typescript-eslint/parser", + settings: { + "import/resolver": { + typescript: {}, + node: { + moduleDirectory: ["node_modules", "."], + }, + }, + }, + rules: {} +} \ No newline at end of file diff --git a/admin/.prettierignore b/admin/.prettierignore new file mode 100644 index 000000000..43e8a7b8f --- /dev/null +++ b/admin/.prettierignore @@ -0,0 +1,6 @@ +.next +.vercel +.tubro +out/ +dis/ +build/ \ No newline at end of file diff --git a/admin/.prettierrc b/admin/.prettierrc new file mode 100644 index 000000000..87d988f1b --- /dev/null +++ b/admin/.prettierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "trailingComma": "es5" +} diff --git a/admin/Dockerfile.admin b/admin/Dockerfile.admin new file mode 100644 index 000000000..9abc5daef --- /dev/null +++ b/admin/Dockerfile.admin @@ -0,0 +1,51 @@ +FROM node:18-alpine AS builder +RUN apk add --no-cache libc6-compat +WORKDIR /app + +RUN yarn global add turbo +COPY . . + +RUN turbo prune --scope=admin --docker + +FROM node:18-alpine AS installer + +RUN apk add --no-cache libc6-compat +WORKDIR /app + +COPY .gitignore .gitignore +COPY --from=builder /app/out/json/ . +COPY --from=builder /app/out/yarn.lock ./yarn.lock +RUN yarn install --network-timeout 500000 + +COPY --from=builder /app/out/full/ . +COPY turbo.json turbo.json + +ARG NEXT_PUBLIC_API_BASE_URL="" +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 + +RUN yarn turbo run build --filter=admin + +FROM node:18-alpine AS runner +WORKDIR /app + +COPY --from=installer /app/admin/next.config.js . +COPY --from=installer /app/admin/package.json . + +COPY --from=installer /app/admin/.next/standalone ./ +COPY --from=installer /app/admin/.next/static ./admin/.next/static +COPY --from=installer /app/admin/public ./admin/public + + +ARG NEXT_PUBLIC_API_BASE_URL="" +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 + +ENV NEXT_TELEMETRY_DISABLED 1 +ENV TURBO_TELEMETRY_DISABLED 1 + +EXPOSE 3000 \ No newline at end of file diff --git a/admin/app/ai/components/ai-config-form.tsx b/admin/app/ai/components/ai-config-form.tsx new file mode 100644 index 000000000..d61eb9ed9 --- /dev/null +++ b/admin/app/ai/components/ai-config-form.tsx @@ -0,0 +1,128 @@ +import { FC } from "react"; +import { useForm } from "react-hook-form"; +import { Lightbulb } from "lucide-react"; +import { Button, TOAST_TYPE, setToast } from "@plane/ui"; +import { IFormattedInstanceConfiguration, TInstanceAIConfigurationKeys } from "@plane/types"; +// components +import { ControllerInput, TControllerInputFormField } from "components/common"; +// hooks +import { useInstance } from "@/hooks"; + +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/components/index.ts b/admin/app/ai/components/index.ts new file mode 100644 index 000000000..2a7609401 --- /dev/null +++ b/admin/app/ai/components/index.ts @@ -0,0 +1 @@ +export * from "./ai-config-form"; diff --git a/admin/app/ai/layout.tsx b/admin/app/ai/layout.tsx new file mode 100644 index 000000000..61df8ebd9 --- /dev/null +++ b/admin/app/ai/layout.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { ReactNode } from "react"; +// layouts +import { AdminLayout } from "@/layouts"; +// lib +import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers"; + +interface AILayoutProps { + children: ReactNode; +} + +const AILayout = ({ children }: AILayoutProps) => ( + + + {children} + + +); + +export default AILayout; diff --git a/admin/app/ai/page.tsx b/admin/app/ai/page.tsx new file mode 100644 index 000000000..71af4a5ba --- /dev/null +++ b/admin/app/ai/page.tsx @@ -0,0 +1,47 @@ +"use client"; + +import useSWR from "swr"; +import { observer } from "mobx-react-lite"; +import { Loader } from "@plane/ui"; +// components +import { PageHeader } from "@/components/core"; +import { InstanceAIForm } from "./components"; +// hooks +import { useInstance } from "@/hooks"; + +const InstanceAIPage = observer(() => { + // store + const { fetchInstanceConfigurations, formattedConfig } = useInstance(); + + useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); + + return ( + <> + +
+
+
AI features for all your workspaces
+
+ Configure your AI API credentials so Plane AI features are turned on for all your workspaces. +
+
+
+ {formattedConfig ? ( + + ) : ( + + +
+ + +
+ +
+ )} +
+
+ + ); +}); + +export default InstanceAIPage; diff --git a/admin/app/authentication/components/common/authentication-method-card.tsx b/admin/app/authentication/components/common/authentication-method-card.tsx new file mode 100644 index 000000000..1346a730e --- /dev/null +++ b/admin/app/authentication/components/common/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/common/index.ts b/admin/app/authentication/components/common/index.ts new file mode 100644 index 000000000..0f5713cdb --- /dev/null +++ b/admin/app/authentication/components/common/index.ts @@ -0,0 +1 @@ +export * from "./authentication-method-card"; diff --git a/admin/app/authentication/components/email-config-switch.tsx b/admin/app/authentication/components/email-config-switch.tsx new file mode 100644 index 000000000..0958b3c42 --- /dev/null +++ b/admin/app/authentication/components/email-config-switch.tsx @@ -0,0 +1,36 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react-lite"; +// hooks +import { useInstance } from "@/hooks"; +// ui +import { ToggleSwitch } from "@plane/ui"; +// types +import { TInstanceAuthenticationMethodKeys } from "@plane/types"; + +type Props = { + disabled: boolean; + updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void; +}; + +export const EmailCodesConfiguration: React.FC = observer((props) => { + const { disabled, updateConfig } = props; + // store + const { formattedConfig } = useInstance(); + // derived values + const enableMagicLogin = formattedConfig?.ENABLE_MAGIC_LINK_LOGIN ?? ""; + + return ( + { + Boolean(parseInt(enableMagicLogin)) === true + ? updateConfig("ENABLE_MAGIC_LINK_LOGIN", "0") + : updateConfig("ENABLE_MAGIC_LINK_LOGIN", "1"); + }} + size="sm" + disabled={disabled} + /> + ); +}); diff --git a/admin/app/authentication/components/index.ts b/admin/app/authentication/components/index.ts new file mode 100644 index 000000000..59760f00d --- /dev/null +++ b/admin/app/authentication/components/index.ts @@ -0,0 +1,3 @@ +export * from "./common"; +export * from "./email-config-switch"; +export * from "./password-config-switch"; diff --git a/admin/app/authentication/components/password-config-switch.tsx b/admin/app/authentication/components/password-config-switch.tsx new file mode 100644 index 000000000..92428e494 --- /dev/null +++ b/admin/app/authentication/components/password-config-switch.tsx @@ -0,0 +1,36 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react-lite"; +// hooks +import { useInstance } from "@/hooks"; +// ui +import { ToggleSwitch } from "@plane/ui"; +// types +import { TInstanceAuthenticationMethodKeys } from "@plane/types"; + +type Props = { + disabled: boolean; + updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void; +}; + +export const PasswordLoginConfiguration: React.FC = observer((props) => { + const { disabled, updateConfig } = props; + // store + const { formattedConfig } = useInstance(); + // derived values + const enableEmailPassword = formattedConfig?.ENABLE_EMAIL_PASSWORD ?? ""; + + return ( + { + Boolean(parseInt(enableEmailPassword)) === true + ? updateConfig("ENABLE_EMAIL_PASSWORD", "0") + : updateConfig("ENABLE_EMAIL_PASSWORD", "1"); + }} + size="sm" + disabled={disabled} + /> + ); +}); diff --git a/admin/app/authentication/github/components/github-config-form.tsx b/admin/app/authentication/github/components/github-config-form.tsx new file mode 100644 index 000000000..22eb11ff4 --- /dev/null +++ b/admin/app/authentication/github/components/github-config-form.tsx @@ -0,0 +1,206 @@ +import { FC, useState } from "react"; +import { useForm } from "react-hook-form"; +import Link from "next/link"; +// hooks +import { useInstance } from "@/hooks"; +// ui +import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; +// components +import { + ConfirmDiscardModal, + ControllerInput, + CopyField, + TControllerInputFormField, + TCopyField, +} from "components/common"; +// types +import { IFormattedInstanceConfiguration, TInstanceGithubAuthenticationConfigurationKeys } from "@plane/types"; +// helpers +import { API_BASE_URL, cn } from "helpers/common.helper"; +import isEmpty from "lodash/isEmpty"; + +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 githubFormFields: 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 githubCopyFields: 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(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success", + message: "Github Configuration Settings updated successfully", + }); + reset(); + }) + .catch((err) => console.error(err)); + }; + + const handleGoBack = (e: React.MouseEvent) => { + if (isDirty) { + e.preventDefault(); + setIsDiscardChangesModalOpen(true); + } + }; + + return ( + <> + setIsDiscardChangesModalOpen(false)} + /> +
+
+
+
Configuration
+ {githubFormFields.map((field) => ( + + ))} +
+
+ + + Go back + +
+
+
+
+
+
Service provider details
+ {githubCopyFields.map((field) => ( + + ))} +
+
+
+
+ + ); +}; diff --git a/admin/app/authentication/github/components/index.ts b/admin/app/authentication/github/components/index.ts new file mode 100644 index 000000000..e9e36e988 --- /dev/null +++ b/admin/app/authentication/github/components/index.ts @@ -0,0 +1,2 @@ +export * from "./root"; +export * from "./github-config-form"; \ No newline at end of file diff --git a/admin/app/authentication/github/components/root.tsx b/admin/app/authentication/github/components/root.tsx new file mode 100644 index 000000000..742462c3b --- /dev/null +++ b/admin/app/authentication/github/components/root.tsx @@ -0,0 +1,59 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; +import { observer } from "mobx-react-lite"; +// hooks +import { useInstance } from "@/hooks"; +// ui +import { ToggleSwitch, getButtonStyling } from "@plane/ui"; +// icons +import { Settings2 } from "lucide-react"; +// types +import { TInstanceAuthenticationMethodKeys } from "@plane/types"; +// helpers +import { cn } from "helpers/common.helper"; + +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/github/page.tsx b/admin/app/authentication/github/page.tsx new file mode 100644 index 000000000..6470f812a --- /dev/null +++ b/admin/app/authentication/github/page.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { useState } from "react"; +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"; +// icons +import githubLightModeImage from "@/public/logos/github-black.png"; +import githubDarkModeImage from "@/public/logos/github-white.png"; + +const InstanceGithubAuthenticationPage = observer(() => { + // store + const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance(); + // state + const [isSubmitting, setIsSubmitting] = useState(false); + // theme + const { resolvedTheme } = useTheme(); + // config + const enableGithubConfig = formattedConfig?.IS_GITHUB_ENABLED ?? ""; + + useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); + + const updateConfig = async (key: "IS_GITHUB_ENABLED", value: string) => { + setIsSubmitting(true); + + const payload = { + [key]: value, + }; + + const updateConfigPromise = updateInstanceConfigurations(payload); + + setPromiseToast(updateConfigPromise, { + loading: "Saving Configuration...", + success: { + title: "Configuration saved", + message: () => `Github authentication is now ${value ? "active" : "disabled"}.`, + }, + error: { + title: "Error", + message: () => "Failed to save configuration", + }, + }); + + await updateConfigPromise + .then(() => { + setIsSubmitting(false); + }) + .catch((err) => { + console.error(err); + setIsSubmitting(false); + }); + }; + return ( + <> + +
+
+ + } + config={ + { + Boolean(parseInt(enableGithubConfig)) === true + ? updateConfig("IS_GITHUB_ENABLED", "0") + : updateConfig("IS_GITHUB_ENABLED", "1"); + }} + size="sm" + disabled={isSubmitting || !formattedConfig} + /> + } + disabled={isSubmitting || !formattedConfig} + withBorder={false} + /> +
+
+ {formattedConfig ? ( + + ) : ( + + + + + + + + )} +
+
+ + ); +}); + +export default InstanceGithubAuthenticationPage; diff --git a/admin/app/authentication/google/components/google-config-form.tsx b/admin/app/authentication/google/components/google-config-form.tsx new file mode 100644 index 000000000..42cea78fd --- /dev/null +++ b/admin/app/authentication/google/components/google-config-form.tsx @@ -0,0 +1,206 @@ +import { FC, useState } from "react"; +import { useForm } from "react-hook-form"; +import Link from "next/link"; +// hooks +import { useInstance } from "@/hooks"; +// ui +import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; +// components +import { + ConfirmDiscardModal, + ControllerInput, + CopyField, + TControllerInputFormField, + TCopyField, +} from "components/common"; +// types +import { IFormattedInstanceConfiguration, TInstanceGoogleAuthenticationConfigurationKeys } from "@plane/types"; +// helpers +import { API_BASE_URL, cn } from "helpers/common.helper"; +import isEmpty from "lodash/isEmpty"; + +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 googleFormFields: 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 googleCopyFeilds: 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(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success", + message: "Google Configuration Settings updated successfully", + }); + reset(); + }) + .catch((err) => console.error(err)); + }; + + const handleGoBack = (e: React.MouseEvent) => { + if (isDirty) { + e.preventDefault(); + setIsDiscardChangesModalOpen(true); + } + }; + + return ( + <> + setIsDiscardChangesModalOpen(false)} + /> +
+
+
+
Configuration
+ {googleFormFields.map((field) => ( + + ))} +
+
+ + + Go back + +
+
+
+
+
+
Service provider details
+ {googleCopyFeilds.map((field) => ( + + ))} +
+
+
+
+ + ); +}; diff --git a/admin/app/authentication/google/components/index.ts b/admin/app/authentication/google/components/index.ts new file mode 100644 index 000000000..d0d37f305 --- /dev/null +++ b/admin/app/authentication/google/components/index.ts @@ -0,0 +1,2 @@ +export * from "./root"; +export * from "./google-config-form"; \ No newline at end of file diff --git a/admin/app/authentication/google/components/root.tsx b/admin/app/authentication/google/components/root.tsx new file mode 100644 index 000000000..6b287476d --- /dev/null +++ b/admin/app/authentication/google/components/root.tsx @@ -0,0 +1,59 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; +import { observer } from "mobx-react-lite"; +// hooks +import { useInstance } from "@/hooks"; +// ui +import { ToggleSwitch, getButtonStyling } from "@plane/ui"; +// icons +import { Settings2 } from "lucide-react"; +// types +import { TInstanceAuthenticationMethodKeys } from "@plane/types"; +// helpers +import { cn } from "helpers/common.helper"; + +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/google/page.tsx b/admin/app/authentication/google/page.tsx new file mode 100644 index 000000000..f7fa6e643 --- /dev/null +++ b/admin/app/authentication/google/page.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useState } from "react"; +import Image from "next/image"; +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 { InstanceGoogleConfigForm } from "./components"; +// hooks +import { useInstance } from "@/hooks"; +// icons +import GoogleLogo from "@/public/logos/google-logo.svg"; + +const InstanceGoogleAuthenticationPage = observer(() => { + // store + const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance(); + // state + const [isSubmitting, setIsSubmitting] = useState(false); + // config + const enableGoogleConfig = formattedConfig?.IS_GOOGLE_ENABLED ?? ""; + + useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); + + const updateConfig = async (key: "IS_GOOGLE_ENABLED", value: string) => { + setIsSubmitting(true); + + const payload = { + [key]: value, + }; + + const updateConfigPromise = updateInstanceConfigurations(payload); + + setPromiseToast(updateConfigPromise, { + loading: "Saving Configuration...", + success: { + title: "Configuration saved", + message: () => `Google authentication is now ${value ? "active" : "disabled"}.`, + }, + error: { + title: "Error", + message: () => "Failed to save configuration", + }, + }); + + await updateConfigPromise + .then(() => { + setIsSubmitting(false); + }) + .catch((err) => { + console.error(err); + setIsSubmitting(false); + }); + }; + return ( + <> + +
+
+ } + config={ + { + Boolean(parseInt(enableGoogleConfig)) === true + ? updateConfig("IS_GOOGLE_ENABLED", "0") + : updateConfig("IS_GOOGLE_ENABLED", "1"); + }} + size="sm" + disabled={isSubmitting || !formattedConfig} + /> + } + disabled={isSubmitting || !formattedConfig} + withBorder={false} + /> +
+
+ {formattedConfig ? ( + + ) : ( + + + + + + + + )} +
+
+ + ); +}); + +export default InstanceGoogleAuthenticationPage; diff --git a/admin/app/authentication/layout.tsx b/admin/app/authentication/layout.tsx new file mode 100644 index 000000000..c6f146ff5 --- /dev/null +++ b/admin/app/authentication/layout.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { ReactNode } from "react"; +// layouts +import { AdminLayout } from "@/layouts"; +// lib +import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers"; + +interface AuthenticationLayoutProps { + children: ReactNode; +} + +const AuthenticationLayout = ({ children }: AuthenticationLayoutProps) => ( + + + {children} + + +); + +export default AuthenticationLayout; diff --git a/admin/app/authentication/page.tsx b/admin/app/authentication/page.tsx new file mode 100644 index 000000000..59e405608 --- /dev/null +++ b/admin/app/authentication/page.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { useState } from "react"; +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"; +// 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"; +// 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"; + +type TInstanceAuthenticationMethodCard = { + key: string; + name: string; + description: string; + icon: JSX.Element; + config: JSX.Element; +}; + +const InstanceAuthenticationPage = observer(() => { + // store + const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance(); + + useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); + + // state + const [isSubmitting, setIsSubmitting] = useState(false); + // theme + const { resolvedTheme } = useTheme(); + + const updateConfig = async (key: TInstanceConfigurationKeys, value: string) => { + setIsSubmitting(true); + + const payload = { + [key]: value, + }; + + const updateConfigPromise = updateInstanceConfigurations(payload); + + setPromiseToast(updateConfigPromise, { + loading: "Saving Configuration...", + success: { + title: "Success", + message: () => "Configuration saved successfully", + }, + error: { + title: "Error", + message: () => "Failed to save configuration", + }, + }); + + await updateConfigPromise + .then(() => { + setIsSubmitting(false); + }) + .catch((err) => { + console.error(err); + setIsSubmitting(false); + }); + }; + + // Authentication methods + const authenticationMethodsCard: TInstanceAuthenticationMethodCard[] = [ + { + key: "email-codes", + name: "Email codes", + description: "Login or sign up using codes sent via emails. You need to have email setup here and enabled.", + icon: , + config: , + }, + { + key: "password-login", + name: "Password based login", + description: "Allow members to create accounts with passwords for emails to sign in.", + icon: , + config: , + }, + { + key: "google", + name: "Google", + description: "Allow members to login or sign up to plane with their Google accounts.", + icon: Google Logo, + config: , + }, + { + key: "github", + name: "Github", + description: "Allow members to login or sign up to plane with their Github accounts.", + icon: ( + GitHub Logo + ), + config: , + }, + ]; + + return ( + <> + +
+
+
Manage authentication for your instance
+
+ Configure authentication modes for your team and restrict sign ups to be invite only. +
+
+
+ {formattedConfig ? ( +
+
Authentication modes
+ {authenticationMethodsCard.map((method) => ( + + ))} +
+ ) : ( + + + + + + + + )} +
+
+ + ); +}); + +export default InstanceAuthenticationPage; diff --git a/admin/app/email/components/email-config-form.tsx b/admin/app/email/components/email-config-form.tsx new file mode 100644 index 000000000..38b50d50f --- /dev/null +++ b/admin/app/email/components/email-config-form.tsx @@ -0,0 +1,221 @@ +import React, { FC, useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +// hooks +import { useInstance } from "@/hooks"; +// ui +import { Button, CustomSelect, TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { ControllerInput, TControllerInputFormField } from "components/common"; +import { SendTestEmailModal } from "./test-email-modal"; +// types +import { IFormattedInstanceConfiguration, TInstanceEmailConfigurationKeys } from "@plane/types"; + +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/components/index.ts b/admin/app/email/components/index.ts new file mode 100644 index 000000000..f06acf7f2 --- /dev/null +++ b/admin/app/email/components/index.ts @@ -0,0 +1,2 @@ +export * from "./email-config-form"; +export * from "./test-email-modal"; diff --git a/admin/app/email/components/test-email-modal.tsx b/admin/app/email/components/test-email-modal.tsx new file mode 100644 index 000000000..6e9f6d9d3 --- /dev/null +++ b/admin/app/email/components/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/email/layout.tsx b/admin/app/email/layout.tsx new file mode 100644 index 000000000..ce1164ead --- /dev/null +++ b/admin/app/email/layout.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { ReactNode } from "react"; +// layouts +import { AdminLayout } from "@/layouts"; +// lib +import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers"; + +interface EmailLayoutProps { + children: ReactNode; +} + +const EmailLayout = ({ children }: EmailLayoutProps) => ( + + + {children} + + +); + +export default EmailLayout; diff --git a/admin/app/email/page.tsx b/admin/app/email/page.tsx new file mode 100644 index 000000000..a3b0bed59 --- /dev/null +++ b/admin/app/email/page.tsx @@ -0,0 +1,50 @@ +"use client"; + +import useSWR from "swr"; +import { observer } from "mobx-react-lite"; +import { Loader } from "@plane/ui"; +// components +import { PageHeader } from "@/components/core"; +import { InstanceEmailForm } from "./components"; +// hooks +import { useInstance } from "@/hooks"; + +const InstanceEmailPage = observer(() => { + // store + const { fetchInstanceConfigurations, formattedConfig } = useInstance(); + + useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); + + return ( + <> + +
+
+
Secure emails from your own instance
+
+ Plane can send useful emails to you and your users from your own instance without talking to the Internet. +
+ Set it up below and please test your settings before you save them.  + Misconfigs can lead to email bounces and errors. +
+
+
+
+ {formattedConfig ? ( + + ) : ( + + + + + + + + )} +
+
+ + ); +}); + +export default InstanceEmailPage; diff --git a/admin/app/general/components/general-config-form.tsx b/admin/app/general/components/general-config-form.tsx new file mode 100644 index 000000000..f45876419 --- /dev/null +++ b/admin/app/general/components/general-config-form.tsx @@ -0,0 +1,136 @@ +import { FC } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { Telescope } from "lucide-react"; +import { IInstance, IInstanceAdmin } from "@plane/types"; +import { Button, Input, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui"; +// components +import { ControllerInput } from "components/common"; +// hooks +import { useInstance } from "@/hooks"; + +export interface IGeneralConfigurationForm { + instance: IInstance["instance"]; + instanceAdmins: IInstanceAdmin[]; +} + +export const GeneralConfigurationForm: FC = (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/components/index.ts b/admin/app/general/components/index.ts new file mode 100644 index 000000000..a144f8d63 --- /dev/null +++ b/admin/app/general/components/index.ts @@ -0,0 +1 @@ +export * from "./general-config-form"; \ No newline at end of file diff --git a/admin/app/general/layout.tsx b/admin/app/general/layout.tsx new file mode 100644 index 000000000..1761f9689 --- /dev/null +++ b/admin/app/general/layout.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { ReactNode } from "react"; +// layouts +import { AdminLayout } from "@/layouts"; +// lib +import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers"; + +interface GeneralLayoutProps { + children: ReactNode; +} + +const GeneralLayout = ({ children }: GeneralLayoutProps) => ( + + + {children} + + +); + +export default GeneralLayout; diff --git a/admin/app/general/page.tsx b/admin/app/general/page.tsx new file mode 100644 index 000000000..10429c1c9 --- /dev/null +++ b/admin/app/general/page.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { observer } from "mobx-react-lite"; +// components +import { PageHeader } from "@/components/core"; +import { GeneralConfigurationForm } from "./components"; +// hooks +import { useInstance } from "@/hooks"; + +const GeneralPage = observer(() => { + const { instance, instanceAdmins } = useInstance(); + + return ( + <> + +
+
+
General settings
+
+ Change the name of your instance and instance admin e-mail addresses. Enable or disable telemetry in your + instance. +
+
+
+ {instance?.instance && instanceAdmins && instanceAdmins?.length > 0 && ( + + )} +
+
+ + ); +}); + +export default GeneralPage; diff --git a/admin/app/globals.css b/admin/app/globals.css new file mode 100644 index 000000000..4a7599d49 --- /dev/null +++ b/admin/app/globals.css @@ -0,0 +1,466 @@ +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;700;800&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@48,400,0,0&display=swap"); + +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer components { + .text-1\.5xl { + font-size: 1.375rem; + line-height: 1.875rem; + } + + .text-2\.5xl { + font-size: 1.75rem; + line-height: 2.25rem; + } +} + +@layer base { + html { + font-family: "Inter", sans-serif; + } + + :root { + color-scheme: light !important; + + --color-primary-10: 236, 241, 255; + --color-primary-20: 217, 228, 255; + --color-primary-30: 197, 214, 255; + --color-primary-40: 178, 200, 255; + --color-primary-50: 159, 187, 255; + --color-primary-60: 140, 173, 255; + --color-primary-70: 121, 159, 255; + --color-primary-80: 101, 145, 255; + --color-primary-90: 82, 132, 255; + --color-primary-100: 63, 118, 255; + --color-primary-200: 57, 106, 230; + --color-primary-300: 50, 94, 204; + --color-primary-400: 44, 83, 179; + --color-primary-500: 38, 71, 153; + --color-primary-600: 32, 59, 128; + --color-primary-700: 25, 47, 102; + --color-primary-800: 19, 35, 76; + --color-primary-900: 13, 24, 51; + + --color-background-100: 255, 255, 255; /* primary bg */ + --color-background-90: 250, 250, 250; /* secondary bg */ + --color-background-80: 245, 245, 245; /* tertiary bg */ + + --color-text-100: 23, 23, 23; /* primary text */ + --color-text-200: 58, 58, 58; /* secondary text */ + --color-text-300: 82, 82, 82; /* tertiary text */ + --color-text-400: 163, 163, 163; /* placeholder text */ + + --color-scrollbar: 163, 163, 163; /* scrollbar thumb */ + + --color-border-100: 245, 245, 245; /* subtle border= 1 */ + --color-border-200: 229, 229, 229; /* subtle border- 2 */ + --color-border-300: 212, 212, 212; /* strong border- 1 */ + --color-border-400: 185, 185, 185; /* strong border- 2 */ + + --color-shadow-2xs: 0px 0px 1px 0px rgba(23, 23, 23, 0.06), + 0px 1px 2px 0px rgba(23, 23, 23, 0.06), + 0px 1px 2px 0px rgba(23, 23, 23, 0.14); + --color-shadow-xs: 0px 1px 2px 0px rgba(0, 0, 0, 0.16), + 0px 2px 4px 0px rgba(16, 24, 40, 0.12), + 0px 1px 8px -1px rgba(16, 24, 40, 0.1); + --color-shadow-sm: 0px 1px 4px 0px rgba(0, 0, 0, 0.01), + 0px 4px 8px 0px rgba(0, 0, 0, 0.02), 0px 1px 12px 0px rgba(0, 0, 0, 0.12); + --color-shadow-rg: 0px 3px 6px 0px rgba(0, 0, 0, 0.1), + 0px 4px 4px 0px rgba(16, 24, 40, 0.08), + 0px 1px 12px 0px rgba(16, 24, 40, 0.04); + --color-shadow-md: 0px 4px 8px 0px rgba(0, 0, 0, 0.12), + 0px 6px 12px 0px rgba(16, 24, 40, 0.12), + 0px 1px 16px 0px rgba(16, 24, 40, 0.12); + --color-shadow-lg: 0px 6px 12px 0px rgba(0, 0, 0, 0.12), + 0px 8px 16px 0px rgba(0, 0, 0, 0.12), + 0px 1px 24px 0px rgba(16, 24, 40, 0.12); + --color-shadow-xl: 0px 0px 18px 0px rgba(0, 0, 0, 0.16), + 0px 0px 24px 0px rgba(16, 24, 40, 0.16), + 0px 0px 52px 0px rgba(16, 24, 40, 0.16); + --color-shadow-2xl: 0px 8px 16px 0px rgba(0, 0, 0, 0.12), + 0px 12px 24px 0px rgba(16, 24, 40, 0.12), + 0px 1px 32px 0px rgba(16, 24, 40, 0.12); + --color-shadow-3xl: 0px 12px 24px 0px rgba(0, 0, 0, 0.12), + 0px 16px 32px 0px rgba(0, 0, 0, 0.12), + 0px 1px 48px 0px rgba(16, 24, 40, 0.12); + --color-shadow-4xl: 0px 8px 40px 0px rgba(0, 0, 61, 0.05), + 0px 12px 32px -16px rgba(0, 0, 0, 0.05); + + --color-sidebar-background-100: var( + --color-background-100 + ); /* primary sidebar bg */ + --color-sidebar-background-90: var( + --color-background-90 + ); /* secondary sidebar bg */ + --color-sidebar-background-80: var( + --color-background-80 + ); /* tertiary sidebar bg */ + + --color-sidebar-text-100: var(--color-text-100); /* primary sidebar text */ + --color-sidebar-text-200: var( + --color-text-200 + ); /* secondary sidebar text */ + --color-sidebar-text-300: var(--color-text-300); /* tertiary sidebar text */ + --color-sidebar-text-400: var( + --color-text-400 + ); /* sidebar placeholder text */ + + --color-sidebar-border-100: var( + --color-border-100 + ); /* subtle sidebar border= 1 */ + --color-sidebar-border-200: var( + --color-border-100 + ); /* subtle sidebar border- 2 */ + --color-sidebar-border-300: var( + --color-border-100 + ); /* strong sidebar border- 1 */ + --color-sidebar-border-400: var( + --color-border-100 + ); /* strong sidebar border- 2 */ + + --color-sidebar-shadow-2xs: var(--color-shadow-2xs); + --color-sidebar-shadow-xs: var(--color-shadow-xs); + --color-sidebar-shadow-sm: var(--color-shadow-sm); + --color-sidebar-shadow-rg: var(--color-shadow-rg); + --color-sidebar-shadow-md: var(--color-shadow-md); + --color-sidebar-shadow-lg: var(--color-shadow-lg); + --color-sidebar-shadow-xl: var(--color-shadow-xl); + --color-sidebar-shadow-2xl: var(--color-shadow-2xl); + --color-sidebar-shadow-3xl: var(--color-shadow-3xl); + --color-sidebar-shadow-4xl: var(--color-shadow-4xl); + } + + [data-theme="light"], + [data-theme="light-contrast"] { + color-scheme: light !important; + + --color-background-100: 255, 255, 255; /* primary bg */ + --color-background-90: 250, 250, 250; /* secondary bg */ + --color-background-80: 245, 245, 245; /* tertiary bg */ + } + + [data-theme="light"] { + --color-text-100: 23, 23, 23; /* primary text */ + --color-text-200: 58, 58, 58; /* secondary text */ + --color-text-300: 82, 82, 82; /* tertiary text */ + --color-text-400: 163, 163, 163; /* placeholder text */ + + --color-scrollbar: 163, 163, 163; /* scrollbar thumb */ + + --color-border-100: 245, 245, 245; /* subtle border= 1 */ + --color-border-200: 229, 229, 229; /* subtle border- 2 */ + --color-border-300: 212, 212, 212; /* strong border- 1 */ + --color-border-400: 185, 185, 185; /* strong border- 2 */ + + /* onboarding colors */ + --gradient-onboarding-100: linear-gradient( + 106deg, + #f2f6ff 29.8%, + #e1eaff 99.34% + ); + --gradient-onboarding-200: linear-gradient( + 129deg, + rgba(255, 255, 255, 0) -22.23%, + rgba(255, 255, 255, 0.8) 62.98% + ); + --gradient-onboarding-300: linear-gradient( + 164deg, + #fff 4.25%, + rgba(255, 255, 255, 0.06) 93.5% + ); + --gradient-onboarding-400: linear-gradient( + 129deg, + rgba(255, 255, 255, 0) -22.23%, + rgba(255, 255, 255, 0.8) 62.98% + ); + + --color-onboarding-text-100: 23, 23, 23; + --color-onboarding-text-200: 58, 58, 58; + --color-onboarding-text-300: 82, 82, 82; + --color-onboarding-text-400: 163, 163, 163; + + --color-onboarding-background-100: 236, 241, 255; + --color-onboarding-background-200: 255, 255, 255; + --color-onboarding-background-300: 236, 241, 255; + --color-onboarding-background-400: 177, 206, 250; + + --color-onboarding-border-100: 229, 229, 229; + --color-onboarding-border-200: 217, 228, 255; + --color-onboarding-border-300: 229, 229, 229, 0.5; + + --color-onboarding-shadow-sm: 0px 4px 20px 0px rgba(126, 139, 171, 0.1); + + /* toast theme */ + --color-toast-success-text: 62, 155, 79; + --color-toast-error-text: 220, 62, 66; + --color-toast-warning-text: 255, 186, 24; + --color-toast-info-text: 51, 88, 212; + --color-toast-loading-text: 28, 32, 36; + --color-toast-secondary-text: 128, 131, 141; + --color-toast-tertiary-text: 96, 100, 108; + + --color-toast-success-background: 253, 253, 254; + --color-toast-error-background: 255, 252, 252; + --color-toast-warning-background: 254, 253, 251; + --color-toast-info-background: 253, 253, 254; + --color-toast-loading-background: 253, 253, 254; + + --color-toast-success-border: 218, 241, 219; + --color-toast-error-border: 255, 219, 220; + --color-toast-warning-border: 255, 247, 194; + --color-toast-info-border: 210, 222, 255; + --color-toast-loading-border: 224, 225, 230; + } + + [data-theme="light-contrast"] { + --color-text-100: 11, 11, 11; /* primary text */ + --color-text-200: 38, 38, 38; /* secondary text */ + --color-text-300: 58, 58, 58; /* tertiary text */ + --color-text-400: 115, 115, 115; /* placeholder text */ + + --color-scrollbar: 115, 115, 115; /* scrollbar thumb */ + + --color-border-100: 34, 34, 34; /* subtle border= 1 */ + --color-border-200: 38, 38, 38; /* subtle border- 2 */ + --color-border-300: 46, 46, 46; /* strong border- 1 */ + --color-border-400: 58, 58, 58; /* strong border- 2 */ + } + + [data-theme="dark"], + [data-theme="dark-contrast"] { + color-scheme: dark !important; + + --color-background-100: 7, 7, 7; /* primary bg */ + --color-background-90: 11, 11, 11; /* secondary bg */ + --color-background-80: 23, 23, 23; /* tertiary bg */ + + --color-shadow-2xs: 0px 0px 1px 0px rgba(0, 0, 0, 0.15), + 0px 1px 3px 0px rgba(0, 0, 0, 0.5); + --color-shadow-xs: 0px 0px 2px 0px rgba(0, 0, 0, 0.2), + 0px 2px 4px 0px rgba(0, 0, 0, 0.5); + --color-shadow-sm: 0px 0px 4px 0px rgba(0, 0, 0, 0.2), + 0px 2px 6px 0px rgba(0, 0, 0, 0.5); + --color-shadow-rg: 0px 0px 6px 0px rgba(0, 0, 0, 0.2), + 0px 4px 6px 0px rgba(0, 0, 0, 0.5); + --color-shadow-md: 0px 2px 8px 0px rgba(0, 0, 0, 0.2), + 0px 4px 8px 0px rgba(0, 0, 0, 0.5); + --color-shadow-lg: 0px 4px 12px 0px rgba(0, 0, 0, 0.25), + 0px 4px 10px 0px rgba(0, 0, 0, 0.55); + --color-shadow-xl: 0px 0px 14px 0px rgba(0, 0, 0, 0.25), + 0px 6px 10px 0px rgba(0, 0, 0, 0.55); + --color-shadow-2xl: 0px 0px 18px 0px rgba(0, 0, 0, 0.25), + 0px 8px 12px 0px rgba(0, 0, 0, 0.6); + --color-shadow-3xl: 0px 4px 24px 0px rgba(0, 0, 0, 0.3), + 0px 12px 40px 0px rgba(0, 0, 0, 0.65); + } + + [data-theme="dark"] { + --color-text-100: 229, 229, 229; /* primary text */ + --color-text-200: 163, 163, 163; /* secondary text */ + --color-text-300: 115, 115, 115; /* tertiary text */ + --color-text-400: 82, 82, 82; /* placeholder text */ + + --color-scrollbar: 82, 82, 82; /* scrollbar thumb */ + + --color-border-100: 34, 34, 34; /* subtle border= 1 */ + --color-border-200: 38, 38, 38; /* subtle border- 2 */ + --color-border-300: 46, 46, 46; /* strong border- 1 */ + --color-border-400: 58, 58, 58; /* strong border- 2 */ + + /* onboarding colors */ + --gradient-onboarding-100: linear-gradient( + 106deg, + #18191b 25.17%, + #18191b 99.34% + ); + --gradient-onboarding-200: linear-gradient( + 129deg, + rgba(47, 49, 53, 0.8) -22.23%, + rgba(33, 34, 37, 0.8) 62.98% + ); + --gradient-onboarding-300: linear-gradient( + 167deg, + rgba(47, 49, 53, 0.45) 19.22%, + #212225 98.48% + ); + + --color-onboarding-text-100: 237, 238, 240; + --color-onboarding-text-200: 176, 180, 187; + --color-onboarding-text-300: 118, 123, 132; + --color-onboarding-text-400: 105, 110, 119; + + --color-onboarding-background-100: 54, 58, 64; + --color-onboarding-background-200: 40, 42, 45; + --color-onboarding-background-300: 40, 42, 45; + --color-onboarding-background-400: 67, 72, 79; + + --color-onboarding-border-100: 54, 58, 64; + --color-onboarding-border-200: 54, 58, 64; + --color-onboarding-border-300: 34, 35, 38, 0.5; + + --color-onboarding-shadow-sm: 0px 4px 20px 0px rgba(39, 44, 56, 0.1); + + /* toast theme */ + --color-toast-success-text: 178, 221, 181; + --color-toast-error-text: 206, 44, 49; + --color-toast-warning-text: 255, 186, 24; + --color-toast-info-text: 141, 164, 239; + --color-toast-loading-text: 255, 255, 255; + --color-toast-secondary-text: 185, 187, 198; + --color-toast-tertiary-text: 139, 141, 152; + + --color-toast-success-background: 46, 46, 46; + --color-toast-error-background: 46, 46, 46; + --color-toast-warning-background: 46, 46, 46; + --color-toast-info-background: 46, 46, 46; + --color-toast-loading-background: 46, 46, 46; + + --color-toast-success-border: 42, 126, 59; + --color-toast-error-border: 100, 23, 35; + --color-toast-warning-border: 79, 52, 34; + --color-toast-info-border: 58, 91, 199; + --color-toast-loading-border: 96, 100, 108; + } + + [data-theme="dark-contrast"] { + --color-text-100: 250, 250, 250; /* primary text */ + --color-text-200: 241, 241, 241; /* secondary text */ + --color-text-300: 212, 212, 212; /* tertiary text */ + --color-text-400: 115, 115, 115; /* placeholder text */ + + --color-scrollbar: 115, 115, 115; /* scrollbar thumb */ + + --color-border-100: 245, 245, 245; /* subtle border= 1 */ + --color-border-200: 229, 229, 229; /* subtle border- 2 */ + --color-border-300: 212, 212, 212; /* strong border- 1 */ + --color-border-400: 185, 185, 185; /* strong border- 2 */ + } + + [data-theme="light"], + [data-theme="dark"], + [data-theme="light-contrast"], + [data-theme="dark-contrast"] { + --color-primary-10: 236, 241, 255; + --color-primary-20: 217, 228, 255; + --color-primary-30: 197, 214, 255; + --color-primary-40: 178, 200, 255; + --color-primary-50: 159, 187, 255; + --color-primary-60: 140, 173, 255; + --color-primary-70: 121, 159, 255; + --color-primary-80: 101, 145, 255; + --color-primary-90: 82, 132, 255; + --color-primary-100: 63, 118, 255; + --color-primary-200: 57, 106, 230; + --color-primary-300: 50, 94, 204; + --color-primary-400: 44, 83, 179; + --color-primary-500: 38, 71, 153; + --color-primary-600: 32, 59, 128; + --color-primary-700: 25, 47, 102; + --color-primary-800: 19, 35, 76; + --color-primary-900: 13, 24, 51; + + --color-sidebar-background-100: var( + --color-background-100 + ); /* primary sidebar bg */ + --color-sidebar-background-90: var( + --color-background-90 + ); /* secondary sidebar bg */ + --color-sidebar-background-80: var( + --color-background-80 + ); /* tertiary sidebar bg */ + + --color-sidebar-text-100: var(--color-text-100); /* primary sidebar text */ + --color-sidebar-text-200: var( + --color-text-200 + ); /* secondary sidebar text */ + --color-sidebar-text-300: var(--color-text-300); /* tertiary sidebar text */ + --color-sidebar-text-400: var( + --color-text-400 + ); /* sidebar placeholder text */ + + --color-sidebar-border-100: var( + --color-border-100 + ); /* subtle sidebar border= 1 */ + --color-sidebar-border-200: var( + --color-border-200 + ); /* subtle sidebar border- 2 */ + --color-sidebar-border-300: var( + --color-border-300 + ); /* strong sidebar border- 1 */ + --color-sidebar-border-400: var( + --color-border-400 + ); /* strong sidebar border- 2 */ + } +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; + font-variant-ligatures: none; + -webkit-font-variant-ligatures: none; + text-rendering: optimizeLegibility; + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; +} + +body { + color: rgba(var(--color-text-100)); +} + +/* scrollbar style */ +::-webkit-scrollbar { + display: none; +} + +.horizontal-scroll-enable { + overflow-x: scroll; +} + +.horizontal-scroll-enable::-webkit-scrollbar { + display: block; + height: 7px; + width: 0; +} + +.horizontal-scroll-enable::-webkit-scrollbar-track { + height: 7px; + background-color: rgba(var(--color-background-100)); +} + +.horizontal-scroll-enable::-webkit-scrollbar-thumb { + border-radius: 5px; + background-color: rgba(var(--color-scrollbar)); +} + +.vertical-scroll-enable::-webkit-scrollbar { + display: block; + width: 5px; +} + +.vertical-scroll-enable::-webkit-scrollbar-track { + width: 5px; +} + +.vertical-scroll-enable::-webkit-scrollbar-thumb { + border-radius: 5px; + background-color: rgba(var(--color-background-90)); +} +/* end scrollbar style */ + +/* progress bar */ +.progress-bar { + fill: currentColor; + color: rgba(var(--color-sidebar-background-100)); +} + +::-webkit-input-placeholder, +::placeholder, +:-ms-input-placeholder { + color: rgb(var(--color-text-400)); +} diff --git a/admin/app/image/components/image-config-form.tsx b/admin/app/image/components/image-config-form.tsx new file mode 100644 index 000000000..722051878 --- /dev/null +++ b/admin/app/image/components/image-config-form.tsx @@ -0,0 +1,79 @@ +import { FC } from "react"; +import { useForm } from "react-hook-form"; +import { Button, TOAST_TYPE, setToast } from "@plane/ui"; +import { IFormattedInstanceConfiguration, TInstanceImageConfigurationKeys } from "@plane/types"; +// components +import { ControllerInput } from "components/common"; +// hooks +import { useInstance } from "@/hooks"; + +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/components/index.ts b/admin/app/image/components/index.ts new file mode 100644 index 000000000..ad9b60a10 --- /dev/null +++ b/admin/app/image/components/index.ts @@ -0,0 +1 @@ +export * from "./image-config-form"; \ No newline at end of file diff --git a/admin/app/image/layout.tsx b/admin/app/image/layout.tsx new file mode 100644 index 000000000..4a42facfb --- /dev/null +++ b/admin/app/image/layout.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { ReactNode } from "react"; +// layouts +import { AdminLayout } from "@/layouts"; +// lib +import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers"; + +interface ImageLayoutProps { + children: ReactNode; +} + +const ImageLayout = ({ children }: ImageLayoutProps) => ( + + + {children} + + +); + +export default ImageLayout; diff --git a/admin/app/image/page.tsx b/admin/app/image/page.tsx new file mode 100644 index 000000000..68572c519 --- /dev/null +++ b/admin/app/image/page.tsx @@ -0,0 +1,43 @@ +"use client"; + +import useSWR from "swr"; +import { observer } from "mobx-react-lite"; +import { Loader } from "@plane/ui"; +// components +import { PageHeader } from "@/components/core"; +import { InstanceImageConfigForm } from "./components"; +// hooks +import { useInstance } from "@/hooks"; + +const InstanceImagePage = observer(() => { + // store + const { formattedConfig, fetchInstanceConfigurations } = useInstance(); + + useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); + + return ( + <> + +
+
+
Third-party image libraries
+
+ Let your users search and choose images from third-party libraries +
+
+
+ {formattedConfig ? ( + + ) : ( + + + + + )} +
+
+ + ); +}); + +export default InstanceImagePage; diff --git a/admin/app/layout.tsx b/admin/app/layout.tsx new file mode 100644 index 000000000..80f85f0c2 --- /dev/null +++ b/admin/app/layout.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { ReactNode } from "react"; +import { ThemeProvider } from "next-themes"; +// lib +import { StoreProvider } from "@/lib/store-context"; +import { AppWrapper } from "@/lib/wrappers"; +// constants +import { SITE_NAME, SITE_DESCRIPTION, SITE_URL, TWITTER_USER_NAME, SITE_KEYWORDS, SITE_TITLE } from "@/constants/seo"; +// styles +import "./globals.css"; + +interface RootLayoutProps { + children: ReactNode; +} + +const RootLayout = ({ children, ...pageProps }: RootLayoutProps) => { + const prefix = parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0") === 0 ? "/" : "/god-mode/"; + + return ( + + + {SITE_TITLE} + + + + + + + + + + + + + + + + + {children} + + + + + ); +}; + +export default RootLayout; diff --git a/web/components/instance/setup-form/index.ts b/admin/app/login/components/index.ts similarity index 57% rename from web/components/instance/setup-form/index.ts rename to admin/app/login/components/index.ts index e9a965d6d..bdeb387f3 100644 --- a/web/components/instance/setup-form/index.ts +++ b/admin/app/login/components/index.ts @@ -1,2 +1 @@ -export * from "./root"; export * from "./sign-in-form"; diff --git a/admin/app/login/components/sign-in-form.tsx b/admin/app/login/components/sign-in-form.tsx new file mode 100644 index 000000000..12df47edf --- /dev/null +++ b/admin/app/login/components/sign-in-form.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { FC, useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; +// services +import { AuthService } from "@/services/auth.service"; +// ui +import { Button, Input, Spinner } from "@plane/ui"; +// components +import { Banner } from "components/common"; +// icons +import { Eye, EyeOff } from "lucide-react"; +// helpers +import { API_BASE_URL } from "@/helpers/common.helper"; + +// 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 })); + + 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/app/login/layout.tsx b/admin/app/login/layout.tsx new file mode 100644 index 000000000..84152390f --- /dev/null +++ b/admin/app/login/layout.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { ReactNode } from "react"; +// lib +import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers"; +// helpers +import { EAuthenticationPageType, EInstancePageType } from "@/helpers"; + +interface LoginLayoutProps { + children: ReactNode; +} + +const LoginLayout = ({ children }: LoginLayoutProps) => ( + + {children} + +); + +export default LoginLayout; diff --git a/admin/app/login/page.tsx b/admin/app/login/page.tsx new file mode 100644 index 000000000..e7edc3fd7 --- /dev/null +++ b/admin/app/login/page.tsx @@ -0,0 +1,18 @@ +"use client"; + +// layouts +import { DefaultLayout } from "@/layouts"; +// components +import { PageHeader } from "@/components/core"; +import { InstanceSignInForm } from "./components"; + +const LoginPage = () => ( + <> + + + + + +); + +export default LoginPage; diff --git a/admin/app/page.tsx b/admin/app/page.tsx new file mode 100644 index 000000000..3b19fb3d6 --- /dev/null +++ b/admin/app/page.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +// components +import { PageHeader } from "@/components/core"; + +const RootPage = () => { + const router = useRouter(); + + useEffect(() => router.push("/login"), [router]); + + return ( + <> + + + ); +}; + +export default RootPage; diff --git a/admin/app/setup/components/index.ts b/admin/app/setup/components/index.ts new file mode 100644 index 000000000..558353b2e --- /dev/null +++ b/admin/app/setup/components/index.ts @@ -0,0 +1 @@ +export * from "./sign-up-form"; diff --git a/admin/app/setup/components/sign-up-form.tsx b/admin/app/setup/components/sign-up-form.tsx new file mode 100644 index 000000000..0058b799e --- /dev/null +++ b/admin/app/setup/components/sign-up-form.tsx @@ -0,0 +1,331 @@ +"use client"; + +import { FC, useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; +// services +import { AuthService } from "@/services/auth.service"; +// ui +import { Button, Checkbox, Input, Spinner } from "@plane/ui"; +// components +import { Banner, PasswordStrengthMeter } from "components/common"; +// icons +import { Eye, EyeOff } from "lucide-react"; +// helpers +import { API_BASE_URL } from "@/helpers/common.helper"; +import { getPasswordStrength } from "@/helpers/password.helper"; + +// 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 InstanceSignUpForm: 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(false); + const [csrfToken, setCsrfToken] = useState(undefined); + const [formData, setFormData] = useState(defaultFromData); + const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + 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] + ); + + 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 ? ( + + ) : ( + + )} +
+ {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" + /> + {showPassword ? ( + + ) : ( + + )} +
+ {!!formData.confirm_password && formData.password !== formData.confirm_password && ( + Passwords don{"'"}t match + )} +
+ +
+
+ handleFormChange("is_telemetry_enabled", !formData.is_telemetry_enabled)} + checked={formData.is_telemetry_enabled} + /> +
+ + + See More + +
+ +
+ +
+
+
+
+ ); +}; diff --git a/admin/app/setup/layout.tsx b/admin/app/setup/layout.tsx new file mode 100644 index 000000000..07f42cd71 --- /dev/null +++ b/admin/app/setup/layout.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { ReactNode } from "react"; +// lib +import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers"; +// helpers +import { EAuthenticationPageType, EInstancePageType } from "@/helpers"; + +interface SetupLayoutProps { + children: ReactNode; +} + +const SetupLayout = ({ children }: SetupLayoutProps) => ( + + {children} + +); + +export default SetupLayout; diff --git a/admin/app/setup/page.tsx b/admin/app/setup/page.tsx new file mode 100644 index 000000000..641155c85 --- /dev/null +++ b/admin/app/setup/page.tsx @@ -0,0 +1,16 @@ +// layouts +import { DefaultLayout } from "@/layouts"; +// components +import { PageHeader } from "@/components/core"; +import { InstanceSignUpForm } from "./components"; + +const SetupPage = () => ( + <> + + + + + +); + +export default SetupPage; diff --git a/web/components/instance/help-section.tsx b/admin/components/admin-sidebar/help-section.tsx similarity index 54% rename from web/components/instance/help-section.tsx rename to admin/components/admin-sidebar/help-section.tsx index 5f6603fe7..606d1bd89 100644 --- a/web/components/instance/help-section.tsx +++ b/admin/components/admin-sidebar/help-section.tsx @@ -1,11 +1,12 @@ +"use client"; + import { FC, useState, useRef } from "react"; -import Link from "next/link"; -import { FileText, HelpCircle, MessagesSquare, MoveLeft } from "lucide-react"; import { Transition } from "@headlessui/react"; +import Link from "next/link"; +import { ExternalLink, FileText, HelpCircle, MoveLeft } from "lucide-react"; +import { DiscordIcon, GithubIcon, Tooltip } from "@plane/ui"; // hooks -import { DiscordIcon, GithubIcon } from "@plane/ui"; -import { useApplication } from "@/hooks/store"; -// icons +import { useTheme } from "@/hooks"; // assets import packageJson from "package.json"; @@ -25,56 +26,56 @@ const helpOptions = [ href: "https://github.com/makeplane/plane/issues/new/choose", Icon: GithubIcon, }, - { - name: "Chat with us", - href: null, - onClick: () => (window as any).$crisp.push(["do", "chat:show"]), - Icon: MessagesSquare, - }, ]; -export const InstanceHelpSection: FC = () => { +export const HelpSection: FC = () => { // states const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false); // store - const { - theme: { sidebarCollapsed, toggleSidebar }, - } = useApplication(); + const { isSidebarCollapsed, toggleSidebar } = useTheme(); // refs const helpOptionsRef = useRef(null); + const redirectionLink = `${process.env.NEXT_PUBLIC_APP_URL ? `${process.env.NEXT_PUBLIC_APP_URL}/create-workspace` : `${process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX === "1" ? `/god-mode/` : `/`}`}`; + return (
-
- - - +
+ + + + {!isSidebarCollapsed && "Redirect to plane"} + + + + + + + +
@@ -89,12 +90,12 @@ export const InstanceHelpSection: FC = () => { >
- {helpOptions.map(({ name, Icon, href, onClick }) => { + {helpOptions.map(({ name, Icon, href }) => { if (href) return ( @@ -111,7 +112,6 @@ export const InstanceHelpSection: FC = () => { + + Go back + +
+ + +
+
+ + + ); +}; diff --git a/admin/components/common/controller-input.tsx b/admin/components/common/controller-input.tsx new file mode 100644 index 000000000..d47fe43f9 --- /dev/null +++ b/admin/components/common/controller-input.tsx @@ -0,0 +1,82 @@ +"use client"; + +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"; +// helpers +import { cn } from "@/helpers/common.helper"; + +type Props = { + control: Control; + type: "text" | "password"; + name: string; + label: string; + description?: string | JSX.Element; + placeholder: string; + error: boolean; + required: boolean; +}; + +export type TControllerInputFormField = { + key: string; + type: "text" | "password"; + label: string; + description?: string | JSX.Element; + placeholder: string; + error: boolean; + required: boolean; +}; + +export const ControllerInput: React.FC = (props) => { + const { name, control, type, label, description, placeholder, error, required } = props; + // states + const [showPassword, setShowPassword] = useState(false); + + return ( +
+

{label}

+
+ ( + + )} + /> + {type === "password" && + (showPassword ? ( + + ) : ( + + ))} +
+ {description &&

{description}

} +
+ ); +}; diff --git a/admin/components/common/copy-field.tsx b/admin/components/common/copy-field.tsx new file mode 100644 index 000000000..d6368b6e9 --- /dev/null +++ b/admin/components/common/copy-field.tsx @@ -0,0 +1,46 @@ +"use client"; + +import React from "react"; +// ui +import { Button, TOAST_TYPE, setToast } from "@plane/ui"; +// icons +import { Copy } from "lucide-react"; + +type Props = { + label: string; + url: string; + description: string | JSX.Element; +}; + +export type TCopyField = { + key: string; + label: string; + url: string; + description: string | JSX.Element; +}; + +export const CopyField: React.FC = (props) => { + const { label, url, description } = props; + + return ( +
+

{label}

+ +

{description}

+
+ ); +}; diff --git a/admin/components/common/index.ts b/admin/components/common/index.ts new file mode 100644 index 000000000..97248b999 --- /dev/null +++ b/admin/components/common/index.ts @@ -0,0 +1,6 @@ +export * from "./breadcrumb-link"; +export * from "./confirm-discard-modal"; +export * from "./controller-input"; +export * from "./copy-field"; +export * from "./password-strength-meter"; +export * from "./banner"; diff --git a/admin/components/common/password-strength-meter.tsx b/admin/components/common/password-strength-meter.tsx new file mode 100644 index 000000000..fabb186f9 --- /dev/null +++ b/admin/components/common/password-strength-meter.tsx @@ -0,0 +1,69 @@ +"use client"; + +// helpers +import { cn } from "@/helpers/common.helper"; +import { getPasswordStrength } from "@/helpers/password.helper"; +// icons +import { CircleCheck } from "lucide-react"; + +type Props = { + password: string; +}; + +export const PasswordStrengthMeter: React.FC = (props: Props) => { + const { password } = props; + + const strength = getPasswordStrength(password); + let bars = []; + let text = ""; + let textColor = ""; + + if (password.length === 0) { + bars = [`bg-[#F0F0F3]`, `bg-[#F0F0F3]`, `bg-[#F0F0F3]`]; + text = "Password requirements"; + } else if (password.length < 8) { + bars = [`bg-[#DC3E42]`, `bg-[#F0F0F3]`, `bg-[#F0F0F3]`]; + text = "Password is too short"; + textColor = `text-[#DC3E42]`; + } else if (strength < 3) { + bars = [`bg-[#FFBA18]`, `bg-[#FFBA18]`, `bg-[#F0F0F3]`]; + text = "Password is weak"; + textColor = `text-[#FFBA18]`; + } else { + bars = [`bg-[#3E9B4F]`, `bg-[#3E9B4F]`, `bg-[#3E9B4F]`]; + text = "Password is strong"; + textColor = `text-[#3E9B4F]`; + } + + const criteria = [ + { label: "Min 8 characters", isValid: password.length >= 8 }, + { label: "Min 1 upper-case letter", isValid: /[A-Z]/.test(password) }, + { label: "Min 1 number", isValid: /\d/.test(password) }, + { label: "Min 1 special character", isValid: /[!@#$%^&*]/.test(password) }, + ]; + + return ( +
+
+ {bars.map((color, index) => ( +
+ ))} +
+

{text}

+
+ {criteria.map((criterion, index) => ( +
+ + {criterion.label} +
+ ))} +
+
+ ); +}; diff --git a/admin/components/core/index.ts b/admin/components/core/index.ts new file mode 100644 index 000000000..d32aafe96 --- /dev/null +++ b/admin/components/core/index.ts @@ -0,0 +1 @@ +export * from "./page-header"; diff --git a/admin/components/core/page-header.tsx b/admin/components/core/page-header.tsx new file mode 100644 index 000000000..a4b27b92f --- /dev/null +++ b/admin/components/core/page-header.tsx @@ -0,0 +1,17 @@ +"use client"; + +type TPageHeader = { + title?: string; + description?: string; +}; + +export const PageHeader: React.FC = (props) => { + const { title = "God Mode - Plane", description = "Plane god mode" } = props; + + return ( + <> + {title} + + + ); +}; diff --git a/admin/components/instance/index.ts b/admin/components/instance/index.ts new file mode 100644 index 000000000..373ba7057 --- /dev/null +++ b/admin/components/instance/index.ts @@ -0,0 +1 @@ +export * from "./instance-not-ready"; diff --git a/admin/components/instance/instance-not-ready.tsx b/admin/components/instance/instance-not-ready.tsx new file mode 100644 index 000000000..d3df5bd66 --- /dev/null +++ b/admin/components/instance/instance-not-ready.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { FC } from "react"; +import Link from "next/link"; +import Image from "next/image"; +import { Button } from "@plane/ui"; +// assets +import PlaneTakeOffImage from "@/public/images/plane-takeoff.png"; + +export const InstanceNotReady: FC = () => ( +
+
+
+

Welcome aboard Plane!

+ Plane Logo +

+ Get started by setting up your instance and workspace +

+
+ +
+ + + +
+
+
+); diff --git a/admin/components/new-user-popup.tsx b/admin/components/new-user-popup.tsx new file mode 100644 index 000000000..9acb1bf17 --- /dev/null +++ b/admin/components/new-user-popup.tsx @@ -0,0 +1,54 @@ +"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"; +// icons +import TakeoffIconLight from "/public/logos/takeoff-icon-light.svg"; +import TakeoffIconDark from "/public/logos/takeoff-icon-dark.svg"; +import { useTheme } from "@/hooks"; + +export const NewUserPopup: React.FC = observer(() => { + // hooks + const { isNewUserPopup, toggleNewUserPopup } = useTheme(); + // theme + const { resolvedTheme } = nextUseTheme(); + + const redirectionLink = `${process.env.NEXT_PUBLIC_APP_URL ? `${process.env.NEXT_PUBLIC_APP_URL}/create-workspace` : `${process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX === "1" ? `/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/constants/swr-config.ts b/admin/constants/swr-config.ts new file mode 100644 index 000000000..38478fcea --- /dev/null +++ b/admin/constants/swr-config.ts @@ -0,0 +1,8 @@ +export const SWR_CONFIG = { + refreshWhenHidden: false, + revalidateIfStale: false, + revalidateOnFocus: false, + revalidateOnMount: true, + refreshInterval: 600000, + errorRetryCount: 3, +}; diff --git a/admin/helpers/authentication.helper.tsx b/admin/helpers/authentication.helper.tsx new file mode 100644 index 000000000..f34fd8758 --- /dev/null +++ b/admin/helpers/authentication.helper.tsx @@ -0,0 +1,126 @@ +import { ReactNode } from "react"; + +export enum EPageTypes { + "PUBLIC" = "PUBLIC", + "NON_AUTHENTICATED" = "NON_AUTHENTICATED", + "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 EAuthenticationErrorCodes { + INSTANCE_NOT_CONFIGURED = "5000", + // Admin + ADMIN_ALREADY_EXIST = "5029", + REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME = "5030", + INVALID_ADMIN_EMAIL = "5031", + INVALID_ADMIN_PASSWORD = "5032", + REQUIRED_ADMIN_EMAIL_PASSWORD = "5033", + ADMIN_AUTHENTICATION_FAILED = "5034", + ADMIN_USER_ALREADY_EXIST = "5035", + ADMIN_USER_DOES_NOT_EXIST = "5036", +} + +export enum EErrorAlertType { + BANNER_ALERT = "BANNER_ALERT", + TOAST_ALERT = "TOAST_ALERT", + INLINE_FIRST_NAME = "INLINE_FIRST_NAME", + INLINE_EMAIL = "INLINE_EMAIL", + INLINE_PASSWORD = "INLINE_PASSWORD", + INLINE_EMAIL_CODE = "INLINE_EMAIL_CODE", +} + +export type TAuthErrorInfo = { + type: EErrorAlertType; + code: EAuthenticationErrorCodes; + title: string; + message: ReactNode; +}; + +const errorCodeMessages: { + [key in EAuthenticationErrorCodes]: { title: string; message: (email?: string | undefined) => ReactNode }; +} = { + [EAuthenticationErrorCodes.INSTANCE_NOT_CONFIGURED]: { + title: "Instance not configured", + message: () => "Please contact your administrator to configure the instance.", + }, + [EAuthenticationErrorCodes.ADMIN_ALREADY_EXIST]: { + title: "Admin already exists", + message: () => "Admin already exists. Please sign in.", + }, + [EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME]: { + title: "Required", + message: () => "Please enter email, password and first name.", + }, + [EAuthenticationErrorCodes.INVALID_ADMIN_EMAIL]: { + title: "Invalid email", + message: () => "Please enter a valid email.", + }, + [EAuthenticationErrorCodes.INVALID_ADMIN_PASSWORD]: { + title: "Invalid password", + message: () => "Password must be at least 8 characters long.", + }, + [EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD]: { + title: "Required", + message: () => "Please enter email and password.", + }, + [EAuthenticationErrorCodes.ADMIN_AUTHENTICATION_FAILED]: { + title: "Authentication failed", + message: () => "Please check your email and password and try again.", + }, + [EAuthenticationErrorCodes.ADMIN_USER_ALREADY_EXIST]: { + title: "User already exists", + message: () => "User already exists. Please sign in.", + }, + [EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST]: { + title: "User does not exist", + message: () => "User does not exist. Please sign up.", + }, +}; + +export const authErrorHandler = ( + errorCode: EAuthenticationErrorCodes, + email?: string | undefined +): TAuthErrorInfo | undefined => { + const toastAlertErrorCodes = [ + EAuthenticationErrorCodes.ADMIN_ALREADY_EXIST, + EAuthenticationErrorCodes.INVALID_ADMIN_EMAIL, + EAuthenticationErrorCodes.INVALID_ADMIN_PASSWORD, + EAuthenticationErrorCodes.ADMIN_AUTHENTICATION_FAILED, + ]; + const bannerAlertErrorCodes = [ + EAuthenticationErrorCodes.INSTANCE_NOT_CONFIGURED, + EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME, + EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD, + EAuthenticationErrorCodes.ADMIN_USER_ALREADY_EXIST, + EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST, + ]; + + if (toastAlertErrorCodes.includes(errorCode)) + return { + type: EErrorAlertType.TOAST_ALERT, + code: errorCode, + title: errorCodeMessages[errorCode]?.title || "Error", + message: errorCodeMessages[errorCode]?.message(email) || "Something went wrong. Please try again.", + }; + + if (bannerAlertErrorCodes.includes(errorCode)) + return { + type: EErrorAlertType.BANNER_ALERT, + code: errorCode, + title: errorCodeMessages[errorCode]?.title || "Error", + message: errorCodeMessages[errorCode]?.message(email) || "Something went wrong. Please try again.", + }; + + return undefined; +}; diff --git a/admin/helpers/common.helper.ts b/admin/helpers/common.helper.ts new file mode 100644 index 000000000..3bf03024b --- /dev/null +++ b/admin/helpers/common.helper.ts @@ -0,0 +1,9 @@ +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 cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs)); + +export const resolveGeneralTheme = (resolvedTheme: string | undefined) => + resolvedTheme?.includes("light") ? "light" : resolvedTheme?.includes("dark") ? "dark" : "system"; diff --git a/admin/helpers/index.ts b/admin/helpers/index.ts new file mode 100644 index 000000000..ae6aab829 --- /dev/null +++ b/admin/helpers/index.ts @@ -0,0 +1,2 @@ +export * from "./instance.helper"; +export * from "./user.helper"; diff --git a/admin/helpers/instance.helper.ts b/admin/helpers/instance.helper.ts new file mode 100644 index 000000000..f929b2211 --- /dev/null +++ b/admin/helpers/instance.helper.ts @@ -0,0 +1,9 @@ +export enum EInstanceStatus { + ERROR = "ERROR", + NOT_YET_READY = "NOT_YET_READY", +} + +export type TInstanceStatus = { + status: EInstanceStatus | undefined; + data?: object; +}; diff --git a/admin/helpers/password.helper.ts b/admin/helpers/password.helper.ts new file mode 100644 index 000000000..8d80b3402 --- /dev/null +++ b/admin/helpers/password.helper.ts @@ -0,0 +1,16 @@ +import zxcvbn from "zxcvbn"; + +export const isPasswordCriteriaMet = (password: string) => { + const criteria = [password.length >= 8, /[A-Z]/.test(password), /\d/.test(password), /[!@#$%^&*]/.test(password)]; + + return criteria.every((criterion) => criterion); +}; + +export const getPasswordStrength = (password: string) => { + if (password.length === 0) return 0; + if (password.length < 8) return 1; + if (!isPasswordCriteriaMet(password)) return 2; + + const result = zxcvbn(password); + return result.score; +}; diff --git a/admin/helpers/user.helper.ts b/admin/helpers/user.helper.ts new file mode 100644 index 000000000..5c6a89a17 --- /dev/null +++ b/admin/helpers/user.helper.ts @@ -0,0 +1,21 @@ +export enum EAuthenticationPageType { + STATIC = "STATIC", + NOT_AUTHENTICATED = "NOT_AUTHENTICATED", + AUTHENTICATED = "AUTHENTICATED", +} + +export enum EInstancePageType { + PRE_SETUP = "PRE_SETUP", + POST_SETUP = "POST_SETUP", +} + +export enum EUserStatus { + ERROR = "ERROR", + AUTHENTICATION_NOT_DONE = "AUTHENTICATION_NOT_DONE", + NOT_YET_READY = "NOT_YET_READY", +} + +export type TUserStatus = { + status: EUserStatus | undefined; + message?: string; +}; diff --git a/admin/hooks/index.ts b/admin/hooks/index.ts new file mode 100644 index 000000000..273970eda --- /dev/null +++ b/admin/hooks/index.ts @@ -0,0 +1,6 @@ +export * from "./use-outside-click-detector"; + +// store-hooks +export * from "./store/use-theme"; +export * from "./store/use-instance"; +export * from "./store/use-user"; diff --git a/admin/hooks/store/use-instance.tsx b/admin/hooks/store/use-instance.tsx new file mode 100644 index 000000000..92165e2bb --- /dev/null +++ b/admin/hooks/store/use-instance.tsx @@ -0,0 +1,10 @@ +import { useContext } from "react"; +// store +import { StoreContext } from "@/lib/store-context"; +import { IInstanceStore } from "@/store/instance.store"; + +export const useInstance = (): IInstanceStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useInstance must be used within StoreProvider"); + return context.instance; +}; diff --git a/admin/hooks/store/use-theme.tsx b/admin/hooks/store/use-theme.tsx new file mode 100644 index 000000000..dc4f9dbf8 --- /dev/null +++ b/admin/hooks/store/use-theme.tsx @@ -0,0 +1,10 @@ +import { useContext } from "react"; +// store +import { StoreContext } from "@/lib/store-context"; +import { IThemeStore } from "@/store/theme.store"; + +export const useTheme = (): IThemeStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useTheme must be used within StoreProvider"); + return context.theme; +}; diff --git a/admin/hooks/store/use-user.tsx b/admin/hooks/store/use-user.tsx new file mode 100644 index 000000000..d1e114ae4 --- /dev/null +++ b/admin/hooks/store/use-user.tsx @@ -0,0 +1,10 @@ +import { useContext } from "react"; +// store +import { StoreContext } from "@/lib/store-context"; +import { IUserStore } from "@/store/user.store"; + +export const useUser = (): IUserStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useUser must be used within StoreProvider"); + return context.user; +}; diff --git a/admin/hooks/use-outside-click-detector.tsx b/admin/hooks/use-outside-click-detector.tsx new file mode 100644 index 000000000..b7b48c857 --- /dev/null +++ b/admin/hooks/use-outside-click-detector.tsx @@ -0,0 +1,21 @@ +"use client"; + +import React, { useEffect } from "react"; + +const useOutsideClickDetector = (ref: React.RefObject, callback: () => void) => { + const handleClick = (event: MouseEvent) => { + if (ref.current && !ref.current.contains(event.target as Node)) { + callback(); + } + }; + + useEffect(() => { + document.addEventListener("mousedown", handleClick); + + return () => { + document.removeEventListener("mousedown", handleClick); + }; + }); +}; + +export default useOutsideClickDetector; diff --git a/admin/layouts/admin-layout.tsx b/admin/layouts/admin-layout.tsx new file mode 100644 index 000000000..131fb7fcd --- /dev/null +++ b/admin/layouts/admin-layout.tsx @@ -0,0 +1,24 @@ +import { FC, ReactNode } from "react"; +// components +import { InstanceSidebar } from "@/components/admin-sidebar"; +import { InstanceHeader } from "@/components/auth-header"; +import { NewUserPopup } from "@/components/new-user-popup"; + +type TAdminLayout = { + children: ReactNode; +}; + +export const AdminLayout: FC = (props) => { + const { children } = props; + + return ( +
+ +
+ +
{children}
+
+ +
+ ); +}; diff --git a/admin/layouts/default-layout.tsx b/admin/layouts/default-layout.tsx new file mode 100644 index 000000000..e0952e994 --- /dev/null +++ b/admin/layouts/default-layout.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { FC, ReactNode } from "react"; +import Image from "next/image"; +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, withoutBackground = false } = props; + // hooks + const { resolvedTheme } = useTheme(); + + return ( +
+
+
+
+ Plane Logo + Plane +
+
+ {!withoutBackground && ( +
+ Plane background pattern +
+ )} +
{children}
+
+
+ ); +}; diff --git a/admin/layouts/index.ts b/admin/layouts/index.ts new file mode 100644 index 000000000..5e4a7c023 --- /dev/null +++ b/admin/layouts/index.ts @@ -0,0 +1,2 @@ +export * from "./default-layout"; +export * from "./admin-layout"; diff --git a/admin/lib/store-context.tsx b/admin/lib/store-context.tsx new file mode 100644 index 000000000..37bba1a71 --- /dev/null +++ b/admin/lib/store-context.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { ReactElement, createContext } from "react"; +// mobx store +import { RootStore } from "@/store/root-store"; + +let rootStore = new RootStore(); + +export const StoreContext = createContext(rootStore); + +const initializeStore = () => { + const newRootStore = rootStore ?? new RootStore(); + if (typeof window === "undefined") return newRootStore; + if (!rootStore) rootStore = newRootStore; + return newRootStore; +}; + +export const StoreProvider = ({ children }: { children: ReactElement }) => { + const store = initializeStore(); + return {children}; +}; diff --git a/admin/lib/wrappers/app-wrapper.tsx b/admin/lib/wrappers/app-wrapper.tsx new file mode 100644 index 000000000..6be1cec24 --- /dev/null +++ b/admin/lib/wrappers/app-wrapper.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { FC, ReactNode, useEffect, Suspense } from "react"; +import { observer } from "mobx-react-lite"; +import { SWRConfig } from "swr"; +// hooks +import { useTheme, useUser } from "@/hooks"; +// ui +import { Toast } from "@plane/ui"; +// constants +import { SWR_CONFIG } from "constants/swr-config"; +// helpers +import { resolveGeneralTheme } from "helpers/common.helper"; + +interface IAppWrapper { + children: ReactNode; +} + +export const AppWrapper: FC = observer(({ children }) => { + // hooks + const { theme, isSidebarCollapsed, toggleSidebar } = useTheme(); + const { currentUser } = useUser(); + + 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/lib/wrappers/auth-wrapper.tsx b/admin/lib/wrappers/auth-wrapper.tsx new file mode 100644 index 000000000..bd3770376 --- /dev/null +++ b/admin/lib/wrappers/auth-wrapper.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { FC, ReactNode } from "react"; +import { observer } from "mobx-react-lite"; +import useSWR from "swr"; +import { Spinner } from "@plane/ui"; +// hooks +import { useInstance, useUser } from "@/hooks"; +// helpers +import { EAuthenticationPageType, EUserStatus } from "@/helpers"; +import { redirect } from "next/navigation"; + +export interface IAuthWrapper { + children: ReactNode; + authType?: EAuthenticationPageType; +} + +export const AuthWrapper: FC = observer((props) => { + const { children, authType = EAuthenticationPageType.AUTHENTICATED } = props; + // hooks + const { instance, fetchInstanceAdmins } = useInstance(); + const { isLoading, userStatus, currentUser, fetchCurrentUser } = useUser(); + + useSWR("CURRENT_USER_DETAILS", () => fetchCurrentUser(), { + shouldRetryOnError: false, + }); + useSWR("INSTANCE_ADMINS", () => fetchInstanceAdmins(), { + shouldRetryOnError: false, + }); + + if (isLoading) + return ( +
+ +
+ ); + + if (userStatus && userStatus?.status === EUserStatus.ERROR) + return ( +
+ Something went wrong. please try again later +
+ ); + + if ([EAuthenticationPageType.AUTHENTICATED, EAuthenticationPageType.NOT_AUTHENTICATED].includes(authType)) { + if (authType === EAuthenticationPageType.NOT_AUTHENTICATED) { + if (currentUser === undefined) return <>{children}; + else redirect("/general/"); + } else { + if (currentUser) return <>{children}; + else { + if (instance?.instance?.is_setup_done) redirect("/login/"); + else redirect("/setup/"); + } + } + } + + return <>{children}; +}); diff --git a/admin/lib/wrappers/index.ts b/admin/lib/wrappers/index.ts new file mode 100644 index 000000000..81c379624 --- /dev/null +++ b/admin/lib/wrappers/index.ts @@ -0,0 +1,3 @@ +export * from "./app-wrapper"; +export * from "./instance-wrapper"; +export * from "./auth-wrapper"; diff --git a/admin/lib/wrappers/instance-wrapper.tsx b/admin/lib/wrappers/instance-wrapper.tsx new file mode 100644 index 000000000..a4444a208 --- /dev/null +++ b/admin/lib/wrappers/instance-wrapper.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { FC, ReactNode } from "react"; +import { redirect, useSearchParams } from "next/navigation"; +import { observer } from "mobx-react-lite"; +import useSWR from "swr"; +import { Spinner } from "@plane/ui"; +// layouts +import { DefaultLayout } from "@/layouts"; +// components +import { InstanceNotReady } from "@/components/instance"; +// hooks +import { useInstance } from "@/hooks"; +// helpers +import { EInstancePageType, EInstanceStatus } from "@/helpers"; + +type TInstanceWrapper = { + children: ReactNode; + pageType?: EInstancePageType; +}; + +export const InstanceWrapper: FC = observer((props) => { + const { children, pageType } = props; + const searchparams = useSearchParams(); + const authEnabled = searchparams.get("auth_enabled") || "1"; + // hooks + const { isLoading, instanceStatus, instance, fetchInstanceInfo } = useInstance(); + + useSWR("INSTANCE_INFORMATION", () => fetchInstanceInfo(), { + revalidateOnFocus: false, + }); + + if (isLoading) + return ( +
+ +
+ ); + + if (instanceStatus && instanceStatus?.status === EInstanceStatus.ERROR) + return ( +
+ Something went wrong. please try again later +
+ ); + + if (instance?.instance?.is_setup_done === false && authEnabled === "1") + return ( + + + + ); + + if (instance?.instance?.is_setup_done && pageType === EInstancePageType.PRE_SETUP) redirect("/"); + + if (!instance?.instance?.is_setup_done && pageType === EInstancePageType.POST_SETUP) redirect("/setup"); + + return <>{children}; +}); diff --git a/admin/next-env.d.ts b/admin/next-env.d.ts new file mode 100644 index 000000000..4f11a03dc --- /dev/null +++ b/admin/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/admin/next.config.js b/admin/next.config.js new file mode 100644 index 000000000..26ff4b870 --- /dev/null +++ b/admin/next.config.js @@ -0,0 +1,13 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + trailingSlash: true, + reactStrictMode: false, + swcMinify: true, + output: "standalone", + images: { + unoptimized: true, + }, + basePath: "/god-mode", +}; + +module.exports = nextConfig; diff --git a/admin/package.json b/admin/package.json new file mode 100644 index 000000000..6a63ea937 --- /dev/null +++ b/admin/package.json @@ -0,0 +1,49 @@ +{ + "name": "admin", + "version": "0.17.0", + "private": true, + "scripts": { + "dev": "turbo run develop", + "develop": "next dev --port 3333", + "build": "next build", + "preview": "next build && next start", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@headlessui/react": "^1.7.19", + "@plane/types": "*", + "@plane/ui": "*", + "@tailwindcss/typography": "^0.5.9", + "@types/lodash": "^4.17.0", + "autoprefixer": "10.4.14", + "axios": "^1.6.7", + "js-cookie": "^3.0.5", + "lodash": "^4.17.21", + "lucide-react": "^0.356.0", + "mobx": "^6.12.0", + "mobx-react-lite": "^4.0.5", + "next": "^14.2.3", + "next-themes": "^0.2.1", + "postcss": "8.4.23", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-hook-form": "^7.51.0", + "swr": "^2.2.4", + "tailwindcss": "3.3.2", + "uuid": "^9.0.1", + "zxcvbn": "^4.4.2" + }, + "devDependencies": { + "@types/js-cookie": "^3.0.6", + "@types/node": "18.16.1", + "@types/react": "^18.2.48", + "@types/react-dom": "^18.2.18", + "@types/uuid": "^9.0.8", + "@types/zxcvbn": "^4.4.4", + "eslint-config-custom": "*", + "tailwind-config-custom": "*", + "tsconfig": "*", + "typescript": "^5.4.2" + } +} diff --git a/admin/postcss.config.js b/admin/postcss.config.js new file mode 100644 index 000000000..6887c8262 --- /dev/null +++ b/admin/postcss.config.js @@ -0,0 +1,8 @@ +module.exports = { + plugins: { + "postcss-import": {}, + "tailwindcss/nesting": {}, + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/admin/public/auth/background-pattern-dark.svg b/admin/public/auth/background-pattern-dark.svg new file mode 100644 index 000000000..c258cbabf --- /dev/null +++ b/admin/public/auth/background-pattern-dark.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/admin/public/auth/background-pattern.svg b/admin/public/auth/background-pattern.svg new file mode 100644 index 000000000..5fcbeec27 --- /dev/null +++ b/admin/public/auth/background-pattern.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/admin/public/favicon/android-chrome-192x192.png b/admin/public/favicon/android-chrome-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..62e95acfc55b0719e3a66c77b211577980dc26a9 GIT binary patch literal 2222 zcmcIm4Q$j@94~H)U>KMgMi8^65XES(?OpG-tGnalZfqCrZH|k~pwX^$4rRM#27LckuYv^A?Reoxrqkd^nAUqfI*4GHEH|$ z-tYbXzyH@u_tXZfmMmVm7(tLFfogvpe1BrycisV?b?0LX;cJmv{iKc{cNJUr0^~sF z3Iw@*xfrT9>e12HF53qU^z@diya{6g=1#Crh%b z4@AZ4Rt;=!4TkvEM&2!8>%3@1f`J4QFgP?J#bljHc(61t1IN}hfuU)L(dfZ8SO(F0 zwifj%8bDn(66ZLfUK3SARAkg*|jkH`!0|h>hQ{!4JZCv095CamB4IN^sJXVb=hN4H6 zf1u7UUuOV@mSyucZm30)@+NenvKhLO4ag1AdZX2P$*7&O+0nIIL|ITw{lRNC06%8{4+gUa^F`qfGDJ}fMKiShA=1e>$PAQK z1ToxlBdF8O(C+J?uw(?z;BE>QcqXi95(ga?B`yL8RgPe2K8%b{i76U14AnWVoe%hY zwVDzZV{k#QtJ;VLDt#`>?Q-FE8M*M< z+1A+%nzEyqh^EVh;drYJJQ#1~9|%}>TD;{5WN6JOfTaIY7a5qYgbkC^z=jBn)=le< zfZ_?OVKaUZe|Isxc1?5V;PAw-hWVoke&i23Aj1<*gGX7k^ySqEQs52vH-r+Mmy`WR zzK*XNn4WTf|Mu82dJnx5+ugAfEgUQDc=Ykq`i)!Hy@W=8s8J6eTCaY9f9xNhL_fOc z;0|UIW$M$4)5RgYYH8amh3gL>)y0T-8_FUF z7q!s~I_^il`hO>ny)jtOGkEb{VzhrY8sBzhA8XE>{gdcAX^!t2e543#OO1`adhzvV zN++Lpw+?(a+)J}Vn-+dsh|Qm(y{Wr@{qp#B?}^mxrSs$WO$7VAqgOATDk-U&syC;m zXC{)vyMp^h-j`h7wp}%^DI+b%eve(*7oX}J+4@vscK_aUbaESYIXJX`(9yHEc=vpS zEyKC|=Zf|b_02BXvwfmH@Q>d z&E(T7p~&g(S@Rvy+4|X;={4>21Iy;K*m9XTxBaI>HT}WEJ!`B#ia=%1|K7%DU;GP+ C)#N__ literal 0 HcmV?d00001 diff --git a/admin/public/favicon/android-chrome-512x512.png b/admin/public/favicon/android-chrome-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..41400832b3142b8085148febc9488408d03cc07d GIT binary patch literal 3832 zcmeHKe{2&~9DjrHV<_Nnrt;%*+aD0Q-j8&dC_;2P$PSC7q5reAji&P?X4DGub7V zd-vYweSd#H@9vrM(&B{+9#{YXSm-WsRRAyx4QGM*bJ62U^yDadWGN-p8UVN5ZQL1P z&)#eR=G-TGtMn?*qbx6nOq?M5peY(s5E_8B1yP0L>!6PLU_g`{_@%?GI3@}XyfV*2 zc@!tC5lb3WxS^rc%Qw{Vb^$L~hpmmWNI(ec92N})C5??b@T6Q8jg4s%$C41e&Vd&i z3b86rIp&mAh*?b(!BZ52+3Y6TYOz?U)fhuD6iMZgG(*rKnOyMNjeuLBrRuBf(vTADk{1tOPC>% z^T}b|fg?*NTnH&?v67Yw6ABm^~jhl?yxxgD?aoT|-zpja6!7 zUDj&ke-NEszD@xOt;dtraYI`|p|lE3FRDjwqyln7wC0T|kgR~399DT)RFBNeF}zV& zrwTb;R=u(uoJ>^tq{$dVn;2|mr6>t=UFTS2ceIEwBU#NcFLE}_$T+fWIk+^z~p}6KkhG6Mbv^^@RsEmjcL>l zTOw*GtFct;Y=B!*qo9Z-%Z255qYWH5Z{!~ecxqa_?0;b7+J;JH5 zFo2?U)4C&(@ubnP6Mm3?buqa%rMV~Jh%t;|nyS!Cn(Uy27*0i0HavbXf>?8>+g0d| z?!DO6TH6D&kDop7>8a``h8Ji2w9>n@asJSvbLCm3&(_UdQuf9x#l0twe$v~Yi=i824Fju0YKplV0y*j)S-E8*3okp4t&&lO}vzdFT0G#E{^`bB2sYL zv8Uz9=k5f`oNH^VTeglx!}*Q#j#SkTb!=+;JeDz&)9F;(UpFLxY3R`5&Y`jJdm7a^ zk6#*Z-kRSqa;3d%O?KZm&4-^JS@F)9{VlUUd;xriw~l>!H8I@x?b-OU=Z6O$ye6%S zp8x*%p$K*C<85pv*tl=WOb<^Vs%lHbs73&~;r+Oz!}F6 zSJlL#W4~Sf;^)^ZZ70T2Mz{PiFwptt$fnWS#3Ga5(Fk7CE({I^6LZHGzbmg8dcuOr z<4Cw~tpC@t`WO9|wuk!W%rNsbI-x2@HYdRLrpG#$gYF+sb#!zc9si{}yRRosgqwq3 v?K;gUu_jpUmjT#k?z(;EO#R<|Ta3?M6)y9&m*=z_|EBJuQrE%tn|J&Pm-{+X literal 0 HcmV?d00001 diff --git a/admin/public/favicon/apple-touch-icon.png b/admin/public/favicon/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..5273d4951dbcf125f8f21386422af21adaa1b609 GIT binary patch literal 2176 zcmcIme@xtD81Ax(Yzx^IRE!hXltf&#*B{&+ySjqx4&gEn2)hlGWvthJcXZsfU0Zg? zjX;7k>P$8>79(S0Gc)lIBF02F32X%UgN&TW#Ayr>#7!}b?Y8Nd;t%|K++CPX7Z+^O z_UrdO-}}DL^S4Ls`$-B7U(1!^=QRXbnQq)m zkRwMQMv(g+65W-0rRzC{mjfnFkUhW@3MdebARF^T3dh$19rXa8D6v?yyB9-6fyF8; zF4CpgL5)}(R>78ViJK4C@-_j>&qFtc7-%2>bPf##{F26mSS)6jfn#Huz|a^(uVt|U z!y#JfDnsqE3Q((w#CejW(OjE}vYO3S@+p)iX_6o<1V!VNl_AXxm5atN40=-qFH`P# zHtq|4u~?0+D-1ydgF#a;$0Vyhg0k6c1W6M#jY9;k)k``T!X+&$VZi}3UKJHxlqJ-# z$a&;CoyB0J^ArL~(yXM#WrBhcAx*Orj_-UO;aU zEEWsS)3g=O%{6m4M_Y1nvxldxW{<~1^B}R`A@g-c2_*Il|8ak*D#8}z{P%3fXiTGa zm|{_bS*?$^&K9scKJtrbtXvq5H`;*3cq9Knz~a;5Ju5J8Hdq6o(SNCnd6*`9^&qE$ z0v}ZCu60Mi;0dE)=Xnr+r5IaV(A)_)JTZ)6@~DCz$-@pv@Pt$0QC9eRQ3Zl5UF~!f zxI;&;e2sUEgGUFhN4vf{e{5&fhpE=KZ_7?vAAGqvkTP=ULJ@at>zWp9XxHj}Z@uyc z^-|wzx%tE|{`U8OJbif>+nztxv*X}5>0=XZowf4m(KPq;dygS=*##|FP5Cm{{u9nM z=Jm~w7Y@IjdgOt{nH-#$T2d9fICHLRwD(GHS9suyo*UEs(ay1JX!9Sds>hj+X8ST$ zEuUR?Ribxg-@nM-=NYv%^v!iwWoMl2ssDLmDl;eN)~7Rh;orMP)@_Tv+1hn|q_1{- zPE~f_Jb;f1!})V(=ccl&GuhtoKqT^7>DaF`gPSMEcAVk8-%t78$@-yb@)Ek35+!5@ zFeC4dU3=!s&hf6EaDOCn?)cvE-)0B;)^-FVk?Cl(vEfqa*5?L+()`A`!SsWf<5g!a zMy+dq+P1yBaq`B~$J?9an+IB3H)oXQH(mbh{K}4?zqRGXMM`BN&KUQ@{TG&=y84}O zsJH1<_ud{us*@Q*dF{3rSbn6zS+M{AahsqrSbcbFDZB6E;$_A^g0ry1(Ya}7^Pi}J B1vCHv literal 0 HcmV?d00001 diff --git a/admin/public/favicon/favicon-16x16.png b/admin/public/favicon/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..8ddbd49c043e06b87fe4c980588a8cba97bfe94e GIT binary patch literal 1625 zcmbVMTWB0r7#@k$UTm-wY!D>Fqy>wanKQGqyP0FgxYE0q{BiG8hxx|_*ElwBI0ALrN)c1i|EP;P%p-Zff?S*^&}dz^?nG+57&dI(5Ap9V*6vm` zcN0!0M!0Q_D*DqJcG#v9&ZVR5*=_0P=otJiol0bTUeNytg1)vbd#1T_pMgEA_MdoN zpa0>Lvnbua>C}stkMbW)>su0S7v9ZHKz}CMzE?TRd_RRgJ+!o3c^up;0RWssSLSEF z{%Ns|uH!R%ySKh!&lWc*U!A<`rOhwTZhUR`lh1hHd|Sxna-)ZTzxDXRGu=;}zU{#2 zbCj}9Nxef7y!VvwU6wfF3MyJ!^0#BZ| z-}C#w{|}_5;=MhO^-vVmn@q%}$xM4=?S16$R4TubX`PkW?NHSH4|&5!9eJydqV9b} zPtUn?@-tAy2B&B^gSe7m5i~`Oj+HD$okuR6K{?%&n49M=Ftn~o%xp*ovK2*nJyEgI zOl2yqR_4`+#*95hkCq@IFp#UzC8JW3L?Hn&eA`u>lyeP5+!8&ErRZ6Vs473?y$Wd+Ga&>Id9;1@M3$DZvO`9edR)^Mf zT5ci^<4cOg3mouFY5+C0gR>UwLPNNw@~D6eWV#N)3LUJK$1ZmA_+O|y*S82Du9fAE zjN9sB7#$IgJGqyb(Q?Rb(N4N-A$}S;c)?cDJIbyLGd=ZRGhP)u==#E__wcnNF(qy+&AK^PRD1R)pzO{k1DJzM@KRD=LP5!?bL zDWfT_a!0VH!YsB8g&3?GN)GXsnPcdV8(|a|uuTLL?EC}>HIG-AUhcqz?vSXKTctCDo@=cNI^9SYsHk2U zNQ~;mA8AbMSifrzG--=@L`eT5FPbn1XWgP=qwyT^)*bVXC(83)!8YyS|88RAZdGx& z;iO}D%TB8zn@+PsCh2fCX=Rm1^Usqucr6(lPtWaFSL*e#&z`>LxbMRkzVgw)Kl& zPrpp=H$A&@V|L5ii}j6%PF}A{xeM=2%#8GW|KlIuoI7(&XTnnL%lao*7yH@Ny8YBg z4-du<^{)A}dZM5ag9@Z9l;gvVoDO!R4BVH!HJ(Yv3P^ zH@r2yO~rjrSnFbIzyI_zJd?INBsNTb)$*(7v}^)`zb0=xT=4o;?bkaZHYM7^JlkdJ zK0G+Tis68=8qj$S3=B*R98_a)G;lhAe8s{bz(6eql1UHNoSpqPiM3)$4da|+t(-sP z&s?=O=GnTgE^Pnmxt47+R*7E~4({PUpl-}l8ohVzzGvLoQ$oM)t;+2B$T;JS!Idlj zp2e?QzpT1g=KTl1ryF0f+zA+JK>!Zbb7yjz9)?{F+Imy(nnd#S)2~$0OV{nYnYiNj ztH`grW}Pv7wjgZ#)w7B5edeM2pS|kUZQHfRE`0s1`!O>4EA7JKeGE+)%8P$*GP>rv z)$H&EhC8A4K8Dw7YM*MCiv0iipEtcshF^O~h71dXg?K$vC0}yz+`njxg HN@xNAL5sc; literal 0 HcmV?d00001 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/plane-takeoff.png b/admin/public/images/plane-takeoff.png new file mode 100644 index 0000000000000000000000000000000000000000..417ff82999890f25a8d61da4e174375b58f457a3 GIT binary patch literal 47818 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

;B*sK>V_Ek<3UIhOnk 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 F|34@0LH+;$ literal 0 HcmV?d00001 diff --git a/admin/public/logos/github-white.png b/admin/public/logos/github-white.png new file mode 100644 index 0000000000000000000000000000000000000000..dbb2b578cb6bc41059e08eea448a9a88d78d6b9a GIT binary patch literal 16559 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 XrLyyQCk|X82tpfJ=+~Ze`RjiH \ No newline at end of file diff --git a/admin/public/logos/takeoff-icon-dark.svg b/admin/public/logos/takeoff-icon-dark.svg new file mode 100644 index 000000000..d3ef19119 --- /dev/null +++ b/admin/public/logos/takeoff-icon-dark.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/admin/public/logos/takeoff-icon-light.svg b/admin/public/logos/takeoff-icon-light.svg new file mode 100644 index 000000000..97cf43fe7 --- /dev/null +++ b/admin/public/logos/takeoff-icon-light.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/admin/public/plane-logos/blue-without-text.png b/admin/public/plane-logos/blue-without-text.png new file mode 100644 index 0000000000000000000000000000000000000000..ea94aec7920b8ee108ed403cd22548712d983a95 GIT binary patch literal 2460 zcmeHJU1%Id9N*a1CXv`Clu{eebpsOdc6W9!x0hSGp6OlE9OTj?IcOBL&h5@6OKx{I zyPMp{g9&}`14<)UH4kkS`cMR`n1`Y*CDjTx)D}Y?jKn5=h&Hr_7A^Qvh_jcwBb8{e zuXbVB*_q$`KYsK3|JipE-TUflch^!BRTt}w_E1#S4sTRHPF8MO(|Pi$u{vLND5}Br zM%6<4nH)uJ*{LV{+`f31tYU+M8crcDXIKPHQG3EU3#x<2rBkS1Hx=ggxhaO$HHA44 zh=aHlK?8bc!A8A>?xb26R6`mQev#gjlZgQXxsc8o8Pkz-3RAQzlexFdGjtK+4k}EW z=aBA;C+G;a5iN0mRRIv`V2Bf>mKF*4Xc35j2LWCXSwWIPi!21`(!&sMww9KAq8%k) ze!?`i!gk#6|6OYUF;0t ze=uFyzRv-nHXg6|*f18usJL+4_7RdsiI5G^PBL#Hz6UvY*j7>d2nn;v%f^x;Hi9m; zlNe{p7nLZ7Op5|1(#;2SQ^Q&3`E>_S6uL-Z$gTNVAg~~i6a^Us<$&}82*`f0290A) zPv_N*ftsqn4WgzPTe85nlF`3FB&S% zGCEz9iwsq-3=~H7?jLDPX<2`02G-1G2M{s(FS=NRIXLZRp^e)530n`;9Z!Piy@FlK zga3Pq#l3aKU51m6;Y};8ihNX>9WqIWvq>u(+cf_QMQ!%QqHW3Cv6Zutz58Bb!Viy_TBm&bW`(79f`5SA5%}Y zRf&fl(>8Cp^#9%Xtz+B74?}myYEGZr`h6<9`lYhCaQfErv+uP&ed2?+W}4EIt7q@+ zIuD;)<&F+7RbMymL>`g0fn3gorw+b$^_0=5Xbp=WqHy72<_KYQi74~%0(gJ?qiqKl zM(0Kw&#MjJU7Yz+uy7%B_4DxP@%{7g?vebb cgtA}wpRf3)FDzbO^!}e>?cLGO_P&1TFD&-(9RL6T literal 0 HcmV?d00001 diff --git a/admin/public/site.webmanifest.json b/admin/public/site.webmanifest.json new file mode 100644 index 000000000..6e5e438f8 --- /dev/null +++ b/admin/public/site.webmanifest.json @@ -0,0 +1,13 @@ +{ + "name": "Plane God Mode", + "short_name": "Plane God Mode", + "description": "Plane helps you plan your issues, cycles, and product modules.", + "start_url": ".", + "display": "standalone", + "background_color": "#f9fafb", + "theme_color": "#3f76ff", + "icons": [ + { "src": "/favicon/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/favicon/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" } + ] +} diff --git a/admin/services/api.service.ts b/admin/services/api.service.ts new file mode 100644 index 000000000..9d692a6c6 --- /dev/null +++ b/admin/services/api.service.ts @@ -0,0 +1,50 @@ +import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios"; + +export abstract class APIService { + protected baseURL: string; + private axiosInstance: AxiosInstance; + + constructor(baseURL: string) { + this.baseURL = baseURL; + this.axiosInstance = axios.create({ + baseURL, + withCredentials: true, + }); + + this.setupInterceptors(); + } + + private setupInterceptors() { + this.axiosInstance.interceptors.response.use( + (response) => response, + (error) => { + if (error.response && error.response.status === 401) window.location.href = "/login"; + return Promise.reject(error); + } + ); + } + + get(url: string, params = {}): Promise> { + return this.axiosInstance.get(url, { params }); + } + + post(url: string, data: RequestType, config = {}): Promise> { + return this.axiosInstance.post(url, data, config); + } + + put(url: string, data: RequestType, config = {}): Promise> { + return this.axiosInstance.put(url, data, config); + } + + patch(url: string, data: RequestType, config = {}): Promise> { + return this.axiosInstance.patch(url, data, config); + } + + delete(url: string, data?: RequestType, config = {}) { + return this.axiosInstance.delete(url, { data, ...config }); + } + + request(config: AxiosRequestConfig = {}): Promise> { + return this.axiosInstance(config); + } +} diff --git a/admin/services/auth.service.ts b/admin/services/auth.service.ts new file mode 100644 index 000000000..6e173140e --- /dev/null +++ b/admin/services/auth.service.ts @@ -0,0 +1,22 @@ +// services +import { APIService } from "services/api.service"; +// helpers +import { API_BASE_URL } from "helpers/common.helper"; + +type TCsrfTokenResponse = { + csrf_token: string; +}; + +export class AuthService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async requestCSRFToken(): Promise { + return this.get("/auth/get-csrf-token/") + .then((response) => response.data) + .catch((error) => { + throw error; + }); + } +} diff --git a/admin/services/index.ts b/admin/services/index.ts new file mode 100644 index 000000000..57313a87f --- /dev/null +++ b/admin/services/index.ts @@ -0,0 +1,3 @@ +export * from "./auth.service"; +export * from "./instance.service"; +export * from "./user.service"; diff --git a/admin/services/instance.service.ts b/admin/services/instance.service.ts new file mode 100644 index 000000000..519adc9f2 --- /dev/null +++ b/admin/services/instance.service.ts @@ -0,0 +1,66 @@ +import { APIService } from "services/api.service"; +// types +import type { IFormattedInstanceConfiguration, IInstance, IInstanceAdmin, IInstanceConfiguration } from "@plane/types"; +// helpers +import { API_BASE_URL } from "helpers/common.helper"; + +export class InstanceService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async getInstanceInfo(): Promise { + return this.get("/api/instances/") + .then((response) => response.data) + .catch((error) => { + throw error; + }); + } + + async getInstanceAdmins(): Promise { + return this.get("/api/instances/admins/") + .then((response) => response.data) + .catch((error) => { + throw error; + }); + } + + async updateInstanceInfo(data: Partial): Promise { + return this.patch, IInstance["instance"]>("/api/instances/", data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async getInstanceConfigurations() { + return this.get("/api/instances/configurations/") + .then((response) => response.data) + .catch((error) => { + throw error; + }); + } + + async updateInstanceConfigurations( + data: Partial + ): Promise { + return this.patch, IInstanceConfiguration[]>( + "/api/instances/configurations/", + data + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async sendTestEmail(receiverEmail: string): Promise { + return this.post<{ receiver_email: string }, undefined>("/api/instances/email-credentials-check/", { + receiver_email: receiverEmail, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/admin/services/user.service.ts b/admin/services/user.service.ts new file mode 100644 index 000000000..9209ec460 --- /dev/null +++ b/admin/services/user.service.ts @@ -0,0 +1,20 @@ +// services +import { APIService } from "services/api.service"; +// types +import type { IUser } from "@plane/types"; +// helpers +import { API_BASE_URL } from "helpers/common.helper"; + +export class UserService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async currentUser(): Promise { + return this.get("/api/instances/admins/me/") + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } +} diff --git a/admin/store/instance.store.ts b/admin/store/instance.store.ts new file mode 100644 index 000000000..70b505ad0 --- /dev/null +++ b/admin/store/instance.store.ts @@ -0,0 +1,163 @@ +import { observable, action, computed, makeObservable, runInAction } from "mobx"; +import set from "lodash/set"; +import { IInstance, IInstanceAdmin, IInstanceConfiguration, IFormattedInstanceConfiguration } from "@plane/types"; +// helpers +import { EInstanceStatus, TInstanceStatus } from "@/helpers"; +// services +import { InstanceService } from "@/services/instance.service"; +// root store +import { RootStore } from "@/store/root-store"; + +export interface IInstanceStore { + // issues + isLoading: boolean; + instanceStatus: TInstanceStatus | undefined; + instance: IInstance | undefined; + instanceAdmins: IInstanceAdmin[] | undefined; + instanceConfigurations: IInstanceConfiguration[] | undefined; + // computed + formattedConfig: IFormattedInstanceConfiguration | undefined; + // action + fetchInstanceInfo: () => Promise; + updateInstanceInfo: (data: Partial) => Promise; + fetchInstanceAdmins: () => Promise; + fetchInstanceConfigurations: () => Promise; + updateInstanceConfigurations: (data: Partial) => Promise; +} + +export class InstanceStore implements IInstanceStore { + isLoading: boolean = true; + instanceStatus: TInstanceStatus | undefined = undefined; + instance: IInstance | undefined = undefined; + instanceAdmins: IInstanceAdmin[] | undefined = undefined; + instanceConfigurations: IInstanceConfiguration[] | undefined = undefined; + // service + instanceService; + + constructor(private store: RootStore) { + makeObservable(this, { + // observable + isLoading: observable.ref, + instanceStatus: observable, + instance: observable, + instanceAdmins: observable, + instanceConfigurations: observable, + // computed + formattedConfig: computed, + // actions + fetchInstanceInfo: action, + fetchInstanceAdmins: action, + updateInstanceInfo: action, + fetchInstanceConfigurations: action, + updateInstanceConfigurations: action, + }); + + this.instanceService = new InstanceService(); + } + + /** + * computed value for instance configurations data for forms. + * @returns configurations in the form of {key, value} pair. + */ + get formattedConfig() { + if (!this.instanceConfigurations) return undefined; + return this.instanceConfigurations?.reduce((formData: IFormattedInstanceConfiguration, config) => { + formData[config.key] = config.value; + return formData; + }, {} as IFormattedInstanceConfiguration); + } + + /** + * @description fetching instance configuration + * @returns {IInstance} instance + */ + fetchInstanceInfo = async () => { + try { + if (this.instance === undefined) this.isLoading = true; + const instance = await this.instanceService.getInstanceInfo(); + // handling the new user popup toggle + if (this.instance === undefined && !instance?.instance?.workspaces_exist) this.store.theme.toggleNewUserPopup(); + runInAction(() => { + this.isLoading = false; + this.instance = instance; + }); + return instance; + } catch (error) { + console.error("Error fetching the instance info"); + this.isLoading = false; + this.instanceStatus = { + status: EInstanceStatus.ERROR, + }; + throw error; + } + }; + + /** + * @description updating instance information + * @param {Partial} data + * @returns void + */ + updateInstanceInfo = async (data: Partial) => { + try { + const instanceResponse = await this.instanceService.updateInstanceInfo(data); + if (instanceResponse) { + runInAction(() => { + if (this.instance) set(this.instance, "instance", instanceResponse); + }); + } + return instanceResponse; + } catch (error) { + console.error("Error updating the instance info"); + throw error; + } + }; + + /** + * @description fetching instance admins + * @return {IInstanceAdmin[]} instanceAdmins + */ + fetchInstanceAdmins = async () => { + try { + const instanceAdmins = await this.instanceService.getInstanceAdmins(); + if (instanceAdmins) runInAction(() => (this.instanceAdmins = instanceAdmins)); + return instanceAdmins; + } catch (error) { + console.error("Error fetching the instance admins"); + throw error; + } + }; + + /** + * @description fetching instance configurations + * @return {IInstanceAdmin[]} instanceConfigurations + */ + fetchInstanceConfigurations = async () => { + try { + const instanceConfigurations = await this.instanceService.getInstanceConfigurations(); + if (instanceConfigurations) runInAction(() => (this.instanceConfigurations = instanceConfigurations)); + return instanceConfigurations; + } catch (error) { + console.error("Error fetching the instance configurations"); + throw error; + } + }; + + /** + * @description updating instance configurations + * @param data + */ + updateInstanceConfigurations = async (data: Partial) => { + try { + await this.instanceService.updateInstanceConfigurations(data).then((response) => { + runInAction(() => { + this.instanceConfigurations = this.instanceConfigurations + ? [...this.instanceConfigurations, ...response] + : 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..85b2a5a8b --- /dev/null +++ b/admin/store/root-store.ts @@ -0,0 +1,25 @@ +import { enableStaticRendering } from "mobx-react-lite"; +// stores +import { IThemeStore, ThemeStore } from "./theme.store"; +import { IInstanceStore, InstanceStore } from "./instance.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); + } + + resetOnSignOut() { + this.theme = new ThemeStore(this); + this.instance = new InstanceStore(this); + this.user = new UserStore(this); + } +} diff --git a/admin/store/theme.store.ts b/admin/store/theme.store.ts new file mode 100644 index 000000000..886507922 --- /dev/null +++ b/admin/store/theme.store.ts @@ -0,0 +1,63 @@ +import { action, observable, makeObservable } from "mobx"; +// root store +import { RootStore } from "@/store/root-store"; + +type TTheme = "dark" | "light"; +export interface IThemeStore { + // observables + isNewUserPopup: boolean; + theme: string | undefined; + isSidebarCollapsed: boolean | undefined; + // actions + 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, + }); + } + + /** + * @description Toggle the new user popup modal + */ + toggleNewUserPopup = () => (this.isNewUserPopup = !this.isNewUserPopup); + + /** + * @description Toggle the sidebar collapsed state + * @param isCollapsed + */ + toggleSidebar = (isCollapsed: boolean) => { + if (isCollapsed === undefined) this.isSidebarCollapsed = !this.isSidebarCollapsed; + else this.isSidebarCollapsed = isCollapsed; + localStorage.setItem("god_mode_sidebar_collapsed", isCollapsed.toString()); + }; + + /** + * @description Sets the user theme and applies it to the platform + * @param currentTheme + */ + setTheme = async (currentTheme: TTheme) => { + try { + localStorage.setItem("theme", currentTheme); + this.theme = currentTheme; + } catch (error) { + console.error("setting user theme error", error); + } + }; +} diff --git a/admin/store/user.store.ts b/admin/store/user.store.ts new file mode 100644 index 000000000..77b81446f --- /dev/null +++ b/admin/store/user.store.ts @@ -0,0 +1,83 @@ +import { action, observable, runInAction, makeObservable } from "mobx"; +import { IUser } from "@plane/types"; +// helpers +import { EUserStatus, TUserStatus } from "@/helpers"; +// services +import { UserService } from "@/services/user.service"; +// root store +import { RootStore } from "@/store/root-store"; +import { AuthService } from "@/services"; + +export interface IUserStore { + // observables + isLoading: boolean; + userStatus: TUserStatus | undefined; + isUserLoggedIn: boolean | undefined; + currentUser: IUser | undefined; + // fetch actions + fetchCurrentUser: () => Promise; + signOut: () => Promise; +} + +export class UserStore implements IUserStore { + // observables + isLoading: boolean = true; + userStatus: TUserStatus | undefined = undefined; + isUserLoggedIn: boolean | undefined = undefined; + currentUser: IUser | undefined = undefined; + // services + userService; + authService; + // rootStore + rootStore; + + constructor(private store: RootStore) { + makeObservable(this, { + // observables + isLoading: observable.ref, + userStatus: observable, + isUserLoggedIn: observable.ref, + currentUser: observable, + // action + fetchCurrentUser: action, + }); + this.userService = new UserService(); + this.authService = new AuthService(); + this.rootStore = store; + } + + /** + * @description Fetches the current user + * @returns Promise + */ + fetchCurrentUser = async () => { + try { + if (this.currentUser === undefined) this.isLoading = true; + const currentUser = await this.userService.currentUser(); + runInAction(() => { + this.isUserLoggedIn = true; + this.currentUser = currentUser; + this.isLoading = false; + }); + return currentUser; + } catch (error: any) { + this.isLoading = false; + this.isUserLoggedIn = false; + if (error.status === 403) + this.userStatus = { + status: EUserStatus.AUTHENTICATION_NOT_DONE, + message: error?.message || "", + }; + else + this.userStatus = { + status: EUserStatus.ERROR, + message: error?.message || "", + }; + throw error; + } + }; + + signOut = async () => { + this.rootStore.resetOnSignOut(); + }; +} diff --git a/admin/tailwind.config.js b/admin/tailwind.config.js new file mode 100644 index 000000000..05bc93bdc --- /dev/null +++ b/admin/tailwind.config.js @@ -0,0 +1,5 @@ +const sharedConfig = require("tailwind-config-custom/tailwind.config.js"); + +module.exports = { + presets: [sharedConfig], +}; diff --git a/admin/tsconfig.json b/admin/tsconfig.json new file mode 100644 index 000000000..5bc5a5684 --- /dev/null +++ b/admin/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "tsconfig/nextjs.json", + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"], + "compilerOptions": { + "baseUrl": ".", + "jsx": "preserve", + "esModuleInterop": true, + "paths": { + "@/*": ["*"] + }, + "plugins": [ + { + "name": "next" + } + ] + } +} diff --git a/apiserver/plane/app/serializers/__init__.py b/apiserver/plane/app/serializers/__init__.py index cd0fc11ce..813c1af21 100644 --- a/apiserver/plane/app/serializers/__init__.py +++ b/apiserver/plane/app/serializers/__init__.py @@ -7,6 +7,8 @@ from .user import ( UserAdminLiteSerializer, UserMeSerializer, UserMeSettingsSerializer, + ProfileSerializer, + AccountSerializer, ) from .workspace import ( WorkSpaceSerializer, diff --git a/apiserver/plane/app/serializers/user.py b/apiserver/plane/app/serializers/user.py index d6c15ee7f..1fff8a90f 100644 --- a/apiserver/plane/app/serializers/user.py +++ b/apiserver/plane/app/serializers/user.py @@ -2,8 +2,15 @@ from rest_framework import serializers # Module import +from plane.db.models import ( + Account, + Profile, + User, + Workspace, + WorkspaceMemberInvite, +) + from .base import BaseSerializer -from plane.db.models import User, Workspace, WorkspaceMemberInvite class UserSerializer(BaseSerializer): @@ -23,7 +30,6 @@ class UserSerializer(BaseSerializer): "last_logout_ip", "last_login_uagent", "token_updated_at", - "is_onboarded", "is_bot", "is_password_autoset", "is_email_verified", @@ -50,19 +56,11 @@ class UserMeSerializer(BaseSerializer): "is_active", "is_bot", "is_email_verified", - "is_managed", - "is_onboarded", - "is_tour_completed", - "mobile_number", - "role", - "onboarding_step", "user_timezone", "username", - "theme", - "last_workspace_id", - "use_case", "is_password_autoset", "is_email_verified", + "last_login_medium", ] read_only_fields = fields @@ -83,25 +81,28 @@ class UserMeSettingsSerializer(BaseSerializer): workspace_invites = WorkspaceMemberInvite.objects.filter( email=obj.email ).count() + + # profile + profile = Profile.objects.get(user=obj) if ( - obj.last_workspace_id is not None + profile.last_workspace_id is not None and Workspace.objects.filter( - pk=obj.last_workspace_id, + pk=profile.last_workspace_id, workspace_member__member=obj.id, workspace_member__is_active=True, ).exists() ): workspace = Workspace.objects.filter( - pk=obj.last_workspace_id, + pk=profile.last_workspace_id, workspace_member__member=obj.id, workspace_member__is_active=True, ).first() return { - "last_workspace_id": obj.last_workspace_id, + "last_workspace_id": profile.last_workspace_id, "last_workspace_slug": ( workspace.slug if workspace is not None else "" ), - "fallback_workspace_id": obj.last_workspace_id, + "fallback_workspace_id": profile.last_workspace_id, "fallback_workspace_slug": ( workspace.slug if workspace is not None else "" ), @@ -200,3 +201,15 @@ class ResetPasswordSerializer(serializers.Serializer): """ new_password = serializers.CharField(required=True, min_length=8) + + +class ProfileSerializer(BaseSerializer): + class Meta: + model = Profile + fields = "__all__" + + +class AccountSerializer(BaseSerializer): + class Meta: + model = Account + fields = "__all__" diff --git a/apiserver/plane/app/urls/__init__.py b/apiserver/plane/app/urls/__init__.py index 40b96687d..cb5f0253a 100644 --- a/apiserver/plane/app/urls/__init__.py +++ b/apiserver/plane/app/urls/__init__.py @@ -1,7 +1,6 @@ from .analytic import urlpatterns as analytic_urls +from .api import urlpatterns as api_urls from .asset import urlpatterns as asset_urls -from .authentication import urlpatterns as authentication_urls -from .config import urlpatterns as configuration_urls from .cycle import urlpatterns as cycle_urls from .dashboard import urlpatterns as dashboard_urls from .estimate import urlpatterns as estimate_urls @@ -16,16 +15,12 @@ from .search import urlpatterns as search_urls from .state import urlpatterns as state_urls from .user import urlpatterns as user_urls from .views import urlpatterns as view_urls -from .workspace import urlpatterns as workspace_urls -from .api import urlpatterns as api_urls from .webhook import urlpatterns as webhook_urls - +from .workspace import urlpatterns as workspace_urls urlpatterns = [ *analytic_urls, *asset_urls, - *authentication_urls, - *configuration_urls, *cycle_urls, *dashboard_urls, *estimate_urls, diff --git a/apiserver/plane/app/urls/authentication.py b/apiserver/plane/app/urls/authentication.py deleted file mode 100644 index e91e5706b..000000000 --- a/apiserver/plane/app/urls/authentication.py +++ /dev/null @@ -1,65 +0,0 @@ -from django.urls import path - -from rest_framework_simplejwt.views import TokenRefreshView - - -from plane.app.views import ( - # Authentication - SignInEndpoint, - SignOutEndpoint, - MagicGenerateEndpoint, - MagicSignInEndpoint, - OauthEndpoint, - EmailCheckEndpoint, - ## End Authentication - # Auth Extended - ForgotPasswordEndpoint, - ResetPasswordEndpoint, - ChangePasswordEndpoint, - ## End Auth Extender - # API Tokens - ApiTokenEndpoint, - ## End API Tokens -) - - -urlpatterns = [ - # Social Auth - path("email-check/", EmailCheckEndpoint.as_view(), name="email"), - path("social-auth/", OauthEndpoint.as_view(), name="oauth"), - # Auth - path("sign-in/", SignInEndpoint.as_view(), name="sign-in"), - path("sign-out/", SignOutEndpoint.as_view(), name="sign-out"), - # magic sign in - path( - "magic-generate/", - MagicGenerateEndpoint.as_view(), - name="magic-generate", - ), - path( - "magic-sign-in/", MagicSignInEndpoint.as_view(), name="magic-sign-in" - ), - path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), - # Password Manipulation - path( - "users/me/change-password/", - ChangePasswordEndpoint.as_view(), - name="change-password", - ), - path( - "reset-password///", - ResetPasswordEndpoint.as_view(), - name="password-reset", - ), - path( - "forgot-password/", - ForgotPasswordEndpoint.as_view(), - name="forgot-password", - ), - # API Tokens - path("api-tokens/", ApiTokenEndpoint.as_view(), name="api-tokens"), - path( - "api-tokens//", ApiTokenEndpoint.as_view(), name="api-tokens" - ), - ## End API Tokens -] diff --git a/apiserver/plane/app/urls/config.py b/apiserver/plane/app/urls/config.py deleted file mode 100644 index 3ea825eb2..000000000 --- a/apiserver/plane/app/urls/config.py +++ /dev/null @@ -1,17 +0,0 @@ -from django.urls import path - - -from plane.app.views import ConfigurationEndpoint, MobileConfigurationEndpoint - -urlpatterns = [ - path( - "configs/", - ConfigurationEndpoint.as_view(), - name="configuration", - ), - path( - "mobile-configs/", - MobileConfigurationEndpoint.as_view(), - name="configuration", - ), -] diff --git a/apiserver/plane/app/urls/user.py b/apiserver/plane/app/urls/user.py index 9dae7b5da..c069467a2 100644 --- a/apiserver/plane/app/urls/user.py +++ b/apiserver/plane/app/urls/user.py @@ -1,20 +1,19 @@ from django.urls import path from plane.app.views import ( - ## User - UserEndpoint, + AccountEndpoint, + ProfileEndpoint, UpdateUserOnBoardedEndpoint, UpdateUserTourCompletedEndpoint, UserActivityEndpoint, - ChangePasswordEndpoint, - SetUserPasswordEndpoint, + UserActivityGraphEndpoint, + ## User + UserEndpoint, + UserIssueCompletedGraphEndpoint, + UserWorkspaceDashboardEndpoint, ## End User ## Workspaces UserWorkSpacesEndpoint, - UserActivityGraphEndpoint, - UserIssueCompletedGraphEndpoint, - UserWorkspaceDashboardEndpoint, - ## End Workspaces ) urlpatterns = [ @@ -39,6 +38,25 @@ urlpatterns = [ ), name="users", ), + # Profile + path( + "users/me/profile/", + ProfileEndpoint.as_view(), + name="accounts", + ), + # End profile + # Accounts + path( + "users/me/accounts/", + AccountEndpoint.as_view(), + name="accounts", + ), + path( + "users/me/accounts//", + AccountEndpoint.as_view(), + name="accounts", + ), + ## End Accounts path( "users/me/instance-admin/", UserEndpoint.as_view( @@ -48,11 +66,6 @@ urlpatterns = [ ), name="users", ), - path( - "users/me/change-password/", - ChangePasswordEndpoint.as_view(), - name="change-password", - ), path( "users/me/onboard/", UpdateUserOnBoardedEndpoint.as_view(), @@ -90,10 +103,5 @@ urlpatterns = [ UserWorkspaceDashboardEndpoint.as_view(), name="user-workspace-dashboard", ), - path( - "users/me/set-password/", - SetUserPasswordEndpoint.as_view(), - name="set-password", - ), ## End User Graph ] diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index bb61aad3a..e652e003e 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -28,7 +28,6 @@ from .user.base import ( UserActivityEndpoint, ) -from .oauth import OauthEndpoint from .base import BaseAPIView, BaseViewSet @@ -92,6 +91,8 @@ from .cycle.base import ( CycleFavoriteViewSet, TransferCycleIssueEndpoint, CycleUserPropertiesEndpoint, + CycleViewSet, + TransferCycleIssueEndpoint, ) from .cycle.issue import ( CycleIssueViewSet, @@ -152,21 +153,6 @@ from .issue.subscriber import ( IssueSubscriberViewSet, ) -from .auth_extended import ( - ForgotPasswordEndpoint, - ResetPasswordEndpoint, - ChangePasswordEndpoint, - SetUserPasswordEndpoint, - EmailCheckEndpoint, - MagicGenerateEndpoint, -) - - -from .authentication import ( - SignInEndpoint, - SignOutEndpoint, - MagicSignInEndpoint, -) from .module.base import ( ModuleViewSet, @@ -200,7 +186,6 @@ from .external.base import ( GPTIntegrationEndpoint, UnsplashEndpoint, ) - from .estimate.base import ( ProjectEstimatePointEndpoint, BulkEstimatePointEndpoint, @@ -219,13 +204,11 @@ from .analytic.base import ( from .notification.base import ( NotificationViewSet, UnreadNotificationEndpoint, - MarkAllReadNotificationViewSet, UserNotificationPreferenceEndpoint, ) from .exporter.base import ExportIssuesEndpoint -from .config import ConfigurationEndpoint, MobileConfigurationEndpoint from .webhook.base import ( WebhookEndpoint, @@ -236,3 +219,7 @@ from .webhook.base import ( from .dashboard.base import DashboardEndpoint, WidgetsEndpoint from .error_404 import custom_404_view + +from .exporter.base import ExportIssuesEndpoint +from .notification.base import MarkAllReadNotificationViewSet +from .user.base import AccountEndpoint, ProfileEndpoint diff --git a/apiserver/plane/app/views/analytic/base.py b/apiserver/plane/app/views/analytic/base.py index 8e0d3220d..256d3cae5 100644 --- a/apiserver/plane/app/views/analytic/base.py +++ b/apiserver/plane/app/views/analytic/base.py @@ -1,5 +1,5 @@ # Django imports -from django.db.models import Count, Sum, F +from django.db.models import Count, F, Sum from django.db.models.functions import ExtractMonth from django.utils import timezone @@ -7,13 +7,14 @@ from django.utils import timezone from rest_framework import status from rest_framework.response import Response -# Module imports -from plane.app.views import BaseAPIView, BaseViewSet from plane.app.permissions import WorkSpaceAdminPermission -from plane.db.models import Issue, AnalyticView, Workspace from plane.app.serializers import AnalyticViewSerializer -from plane.utils.analytics_plot import build_graph_plot + +# Module imports +from plane.app.views.base import BaseAPIView, BaseViewSet from plane.bgtasks.analytic_plot_export import analytic_export_task +from plane.db.models import AnalyticView, Issue, Workspace +from plane.utils.analytics_plot import build_graph_plot from plane.utils.issue_filters import issue_filters diff --git a/apiserver/plane/app/views/auth_extended.py b/apiserver/plane/app/views/auth_extended.py deleted file mode 100644 index 896f4170f..000000000 --- a/apiserver/plane/app/views/auth_extended.py +++ /dev/null @@ -1,482 +0,0 @@ -## Python imports -import uuid -import os -import json -import random -import string - -## Django imports -from django.contrib.auth.tokens import PasswordResetTokenGenerator -from django.utils.encoding import ( - smart_str, - smart_bytes, - DjangoUnicodeDecodeError, -) -from django.contrib.auth.hashers import make_password -from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode -from django.core.validators import validate_email -from django.core.exceptions import ValidationError - -## Third Party Imports -from rest_framework import status -from rest_framework.response import Response -from rest_framework.permissions import AllowAny -from rest_framework_simplejwt.tokens import RefreshToken - -## Module imports -from . import BaseAPIView -from plane.app.serializers import ( - ChangePasswordSerializer, - ResetPasswordSerializer, - UserSerializer, -) -from plane.db.models import User, WorkspaceMemberInvite -from plane.license.utils.instance_value import get_configuration_value -from plane.bgtasks.forgot_password_task import forgot_password -from plane.license.models import Instance -from plane.settings.redis import redis_instance -from plane.bgtasks.magic_link_code_task import magic_link -from plane.bgtasks.event_tracking_task import auth_events - - -def get_tokens_for_user(user): - refresh = RefreshToken.for_user(user) - return ( - str(refresh.access_token), - str(refresh), - ) - - -def generate_magic_token(email): - key = "magic_" + str(email) - - ## Generate a random token - token = ( - "".join(random.choices(string.ascii_lowercase, k=4)) - + "-" - + "".join(random.choices(string.ascii_lowercase, k=4)) - + "-" - + "".join(random.choices(string.ascii_lowercase, k=4)) - ) - - # Initialize the redis instance - ri = redis_instance() - - # Check if the key already exists in python - if ri.exists(key): - data = json.loads(ri.get(key)) - - current_attempt = data["current_attempt"] + 1 - - if data["current_attempt"] > 2: - return key, token, False - - value = { - "current_attempt": current_attempt, - "email": email, - "token": token, - } - expiry = 600 - - ri.set(key, json.dumps(value), ex=expiry) - - else: - value = {"current_attempt": 0, "email": email, "token": token} - expiry = 600 - - ri.set(key, json.dumps(value), ex=expiry) - - return key, token, True - - -def generate_password_token(user): - uidb64 = urlsafe_base64_encode(smart_bytes(user.id)) - token = PasswordResetTokenGenerator().make_token(user) - - return uidb64, token - - -class ForgotPasswordEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] - - def post(self, request): - email = request.data.get("email") - - try: - validate_email(email) - except ValidationError: - return Response( - {"error": "Please enter a valid email"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Get the user - user = User.objects.filter(email=email).first() - if user: - # Get the reset token for user - uidb64, token = generate_password_token(user=user) - current_site = request.META.get("HTTP_ORIGIN") - # send the forgot password email - forgot_password.delay( - user.first_name, user.email, uidb64, token, current_site - ) - return Response( - {"message": "Check your email to reset your password"}, - status=status.HTTP_200_OK, - ) - return Response( - {"error": "Please check the email"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - -class ResetPasswordEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] - - def post(self, request, uidb64, token): - try: - # Decode the id from the uidb64 - id = smart_str(urlsafe_base64_decode(uidb64)) - user = User.objects.get(id=id) - - # check if the token is valid for the user - if not PasswordResetTokenGenerator().check_token(user, token): - return Response( - {"error": "Token is invalid"}, - status=status.HTTP_401_UNAUTHORIZED, - ) - - # Reset the password - serializer = ResetPasswordSerializer(data=request.data) - if serializer.is_valid(): - # set_password also hashes the password that the user will get - user.set_password(serializer.data.get("new_password")) - user.is_password_autoset = False - user.save() - - # Log the user in - # Generate access token for the user - access_token, refresh_token = get_tokens_for_user(user) - data = { - "access_token": access_token, - "refresh_token": refresh_token, - } - - return Response(data, status=status.HTTP_200_OK) - return Response( - serializer.errors, status=status.HTTP_400_BAD_REQUEST - ) - - except DjangoUnicodeDecodeError: - return Response( - {"error": "token is not valid, please check the new one"}, - status=status.HTTP_401_UNAUTHORIZED, - ) - - -class ChangePasswordEndpoint(BaseAPIView): - def post(self, request): - serializer = ChangePasswordSerializer(data=request.data) - user = User.objects.get(pk=request.user.id) - if serializer.is_valid(): - if not user.check_password(serializer.data.get("old_password")): - return Response( - {"error": "Old password is not correct"}, - status=status.HTTP_400_BAD_REQUEST, - ) - # set_password also hashes the password that the user will get - user.set_password(serializer.data.get("new_password")) - user.is_password_autoset = False - user.save() - return Response( - {"message": "Password updated successfully"}, - status=status.HTTP_200_OK, - ) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - -class SetUserPasswordEndpoint(BaseAPIView): - def post(self, request): - user = User.objects.get(pk=request.user.id) - password = request.data.get("password", False) - - # If the user password is not autoset then return error - if not user.is_password_autoset: - return Response( - { - "error": "Your password is already set please change your password from profile" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Check password validation - if not password and len(str(password)) < 8: - return Response( - {"error": "Password is not valid"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Set the user password - user.set_password(password) - user.is_password_autoset = False - user.save() - serializer = UserSerializer(user) - return Response(serializer.data, status=status.HTTP_200_OK) - - -class MagicGenerateEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] - - def post(self, request): - email = request.data.get("email", False) - - # Check the instance registration - instance = Instance.objects.first() - if instance is None or not instance.is_setup_done: - return Response( - {"error": "Instance is not configured"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - if not email: - return Response( - {"error": "Please provide a valid email address"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Clean up the email - email = email.strip().lower() - validate_email(email) - - # check if the email exists not - if not User.objects.filter(email=email).exists(): - # Create a user - _ = User.objects.create( - email=email, - username=uuid.uuid4().hex, - password=make_password(uuid.uuid4().hex), - is_password_autoset=True, - ) - - ## Generate a random token - token = ( - "".join(random.choices(string.ascii_lowercase, k=4)) - + "-" - + "".join(random.choices(string.ascii_lowercase, k=4)) - + "-" - + "".join(random.choices(string.ascii_lowercase, k=4)) - ) - - ri = redis_instance() - - key = "magic_" + str(email) - - # Check if the key already exists in python - if ri.exists(key): - data = json.loads(ri.get(key)) - - current_attempt = data["current_attempt"] + 1 - - if data["current_attempt"] > 2: - return Response( - { - "error": "Max attempts exhausted. Please try again later." - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - value = { - "current_attempt": current_attempt, - "email": email, - "token": token, - } - expiry = 600 - - ri.set(key, json.dumps(value), ex=expiry) - - else: - value = {"current_attempt": 0, "email": email, "token": token} - expiry = 600 - - ri.set(key, json.dumps(value), ex=expiry) - - # If the smtp is configured send through here - current_site = request.META.get("HTTP_ORIGIN") - magic_link.delay(email, key, token, current_site) - - return Response({"key": key}, status=status.HTTP_200_OK) - - -class EmailCheckEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] - - def post(self, request): - # Check the instance registration - instance = Instance.objects.first() - if instance is None or not instance.is_setup_done: - return Response( - {"error": "Instance is not configured"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Get configuration values - ENABLE_SIGNUP, ENABLE_MAGIC_LINK_LOGIN = get_configuration_value( - [ - { - "key": "ENABLE_SIGNUP", - "default": os.environ.get("ENABLE_SIGNUP"), - }, - { - "key": "ENABLE_MAGIC_LINK_LOGIN", - "default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN"), - }, - ] - ) - - email = request.data.get("email", False) - - if not email: - return Response( - {"error": "Email is required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # validate the email - try: - validate_email(email) - except ValidationError: - return Response( - {"error": "Email is not valid"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Check if the user exists - user = User.objects.filter(email=email).first() - current_site = request.META.get("HTTP_ORIGIN") - - # If new user - if user is None: - # Create the user - if ( - ENABLE_SIGNUP == "0" - and not WorkspaceMemberInvite.objects.filter( - email=email, - ).exists() - ): - return Response( - { - "error": "New account creation is disabled. Please contact your site administrator" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Create the user with default values - user = User.objects.create( - email=email, - username=uuid.uuid4().hex, - password=make_password(uuid.uuid4().hex), - is_password_autoset=True, - ) - - if not bool( - ENABLE_MAGIC_LINK_LOGIN, - ): - return Response( - {"error": "Magic link sign in is disabled."}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Send event - auth_events.delay( - user=user.id, - email=email, - user_agent=request.META.get("HTTP_USER_AGENT"), - ip=request.META.get("REMOTE_ADDR"), - event_name="Sign up", - medium="Magic link", - first_time=True, - ) - key, token, current_attempt = generate_magic_token(email=email) - if not current_attempt: - return Response( - { - "error": "Max attempts exhausted. Please try again later." - }, - status=status.HTTP_400_BAD_REQUEST, - ) - # Trigger the email - magic_link.delay(email, "magic_" + str(email), token, current_site) - return Response( - { - "is_password_autoset": user.is_password_autoset, - "is_existing": False, - }, - status=status.HTTP_200_OK, - ) - - # Existing user - else: - if user.is_password_autoset: - ## Generate a random token - if not bool(ENABLE_MAGIC_LINK_LOGIN): - return Response( - {"error": "Magic link sign in is disabled."}, - status=status.HTTP_400_BAD_REQUEST, - ) - - auth_events.delay( - user=user.id, - email=email, - user_agent=request.META.get("HTTP_USER_AGENT"), - ip=request.META.get("REMOTE_ADDR"), - event_name="Sign in", - medium="Magic link", - first_time=False, - ) - - # Generate magic token - key, token, current_attempt = generate_magic_token(email=email) - if not current_attempt: - return Response( - { - "error": "Max attempts exhausted. Please try again later." - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Trigger the email - magic_link.delay(email, key, token, current_site) - return Response( - { - "is_password_autoset": user.is_password_autoset, - "is_existing": True, - }, - status=status.HTTP_200_OK, - ) - else: - auth_events.delay( - user=user.id, - email=email, - user_agent=request.META.get("HTTP_USER_AGENT"), - ip=request.META.get("REMOTE_ADDR"), - event_name="Sign in", - medium="Email", - first_time=False, - ) - - # User should enter password to login - return Response( - { - "is_password_autoset": user.is_password_autoset, - "is_existing": True, - }, - status=status.HTTP_200_OK, - ) diff --git a/apiserver/plane/app/views/authentication.py b/apiserver/plane/app/views/authentication.py deleted file mode 100644 index 7d898f971..000000000 --- a/apiserver/plane/app/views/authentication.py +++ /dev/null @@ -1,453 +0,0 @@ -# Python imports -import os -import uuid -import json - -# Django imports -from django.utils import timezone -from django.core.exceptions import ValidationError -from django.core.validators import validate_email -from django.contrib.auth.hashers import make_password - -# Third party imports -from rest_framework.response import Response -from rest_framework.permissions import AllowAny -from rest_framework import status -from rest_framework_simplejwt.tokens import RefreshToken -from sentry_sdk import capture_message - -# Module imports -from . import BaseAPIView -from plane.db.models import ( - User, - WorkspaceMemberInvite, - WorkspaceMember, - ProjectMemberInvite, - ProjectMember, -) -from plane.settings.redis import redis_instance -from plane.license.models import Instance -from plane.license.utils.instance_value import get_configuration_value -from plane.bgtasks.event_tracking_task import auth_events - - -def get_tokens_for_user(user): - refresh = RefreshToken.for_user(user) - return ( - str(refresh.access_token), - str(refresh), - ) - - -class SignUpEndpoint(BaseAPIView): - permission_classes = (AllowAny,) - - def post(self, request): - # Check if the instance configuration is done - instance = Instance.objects.first() - if instance is None or not instance.is_setup_done: - return Response( - {"error": "Instance is not configured"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - email = request.data.get("email", False) - password = request.data.get("password", False) - ## Raise exception if any of the above are missing - if not email or not password: - return Response( - {"error": "Both email and password are required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Validate the email - email = email.strip().lower() - try: - validate_email(email) - except ValidationError: - return Response( - {"error": "Please provide a valid email address."}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # get configuration values - # Get configuration values - (ENABLE_SIGNUP,) = get_configuration_value( - [ - { - "key": "ENABLE_SIGNUP", - "default": os.environ.get("ENABLE_SIGNUP"), - }, - ] - ) - - # If the sign up is not enabled and the user does not have invite disallow him from creating the account - if ( - ENABLE_SIGNUP == "0" - and not WorkspaceMemberInvite.objects.filter( - email=email, - ).exists() - ): - return Response( - { - "error": "New account creation is disabled. Please contact your site administrator" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Check if the user already exists - if User.objects.filter(email=email).exists(): - return Response( - {"error": "User with this email already exists"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - user = User.objects.create(email=email, username=uuid.uuid4().hex) - user.set_password(password) - - # settings last actives for the user - user.is_password_autoset = False - user.last_active = timezone.now() - user.last_login_time = timezone.now() - user.last_login_ip = request.META.get("REMOTE_ADDR") - user.last_login_uagent = request.META.get("HTTP_USER_AGENT") - user.token_updated_at = timezone.now() - user.save() - - access_token, refresh_token = get_tokens_for_user(user) - - data = { - "access_token": access_token, - "refresh_token": refresh_token, - } - - return Response(data, status=status.HTTP_200_OK) - - -class SignInEndpoint(BaseAPIView): - permission_classes = (AllowAny,) - - def post(self, request): - # Check if the instance configuration is done - instance = Instance.objects.first() - if instance is None or not instance.is_setup_done: - return Response( - {"error": "Instance is not configured"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - email = request.data.get("email", False) - password = request.data.get("password", False) - - ## Raise exception if any of the above are missing - if not email or not password: - return Response( - {"error": "Both email and password are required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Validate email - email = email.strip().lower() - try: - validate_email(email) - except ValidationError: - return Response( - {"error": "Please provide a valid email address."}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Get the user - user = User.objects.filter(email=email).first() - - # Existing user - if user: - # Check user password - if not user.check_password(password): - return Response( - { - "error": "Sorry, we could not find a user with the provided credentials. Please try again." - }, - status=status.HTTP_403_FORBIDDEN, - ) - - # Create the user - else: - (ENABLE_SIGNUP,) = get_configuration_value( - [ - { - "key": "ENABLE_SIGNUP", - "default": os.environ.get("ENABLE_SIGNUP"), - }, - ] - ) - # Create the user - if ( - ENABLE_SIGNUP == "0" - and not WorkspaceMemberInvite.objects.filter( - email=email, - ).exists() - ): - return Response( - { - "error": "New account creation is disabled. Please contact your site administrator" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - user = User.objects.create( - email=email, - username=uuid.uuid4().hex, - password=make_password(password), - is_password_autoset=False, - ) - - # settings last active for the user - user.is_active = True - user.last_active = timezone.now() - user.last_login_time = timezone.now() - user.last_login_ip = request.META.get("REMOTE_ADDR") - user.last_login_uagent = request.META.get("HTTP_USER_AGENT") - user.token_updated_at = timezone.now() - user.save() - - # Check if user has any accepted invites for workspace and add them to workspace - workspace_member_invites = WorkspaceMemberInvite.objects.filter( - email=user.email, accepted=True - ) - - WorkspaceMember.objects.bulk_create( - [ - WorkspaceMember( - workspace_id=workspace_member_invite.workspace_id, - member=user, - role=workspace_member_invite.role, - ) - for workspace_member_invite in workspace_member_invites - ], - ignore_conflicts=True, - ) - - # Check if user has any project invites - project_member_invites = ProjectMemberInvite.objects.filter( - email=user.email, accepted=True - ) - - # Add user to workspace - WorkspaceMember.objects.bulk_create( - [ - WorkspaceMember( - workspace_id=project_member_invite.workspace_id, - role=( - project_member_invite.role - if project_member_invite.role in [5, 10, 15] - else 15 - ), - member=user, - created_by_id=project_member_invite.created_by_id, - ) - for project_member_invite in project_member_invites - ], - ignore_conflicts=True, - ) - - # Now add the users to project - ProjectMember.objects.bulk_create( - [ - ProjectMember( - workspace_id=project_member_invite.workspace_id, - role=( - project_member_invite.role - if project_member_invite.role in [5, 10, 15] - else 15 - ), - member=user, - created_by_id=project_member_invite.created_by_id, - ) - for project_member_invite in project_member_invites - ], - ignore_conflicts=True, - ) - - # Delete all the invites - workspace_member_invites.delete() - project_member_invites.delete() - # Send event - auth_events.delay( - user=user.id, - email=email, - user_agent=request.META.get("HTTP_USER_AGENT"), - ip=request.META.get("REMOTE_ADDR"), - event_name="Sign in", - medium="Email", - first_time=False, - ) - - access_token, refresh_token = get_tokens_for_user(user) - data = { - "access_token": access_token, - "refresh_token": refresh_token, - } - return Response(data, status=status.HTTP_200_OK) - - -class SignOutEndpoint(BaseAPIView): - def post(self, request): - refresh_token = request.data.get("refresh_token", False) - - if not refresh_token: - capture_message("No refresh token provided") - return Response( - {"error": "No refresh token provided"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - user = User.objects.get(pk=request.user.id) - - user.last_logout_time = timezone.now() - user.last_logout_ip = request.META.get("REMOTE_ADDR") - - user.save() - - token = RefreshToken(refresh_token) - token.blacklist() - return Response({"message": "success"}, status=status.HTTP_200_OK) - - -class MagicSignInEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] - - def post(self, request): - # Check if the instance configuration is done - instance = Instance.objects.first() - if instance is None or not instance.is_setup_done: - return Response( - {"error": "Instance is not configured"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - user_token = request.data.get("token", "").strip() - key = request.data.get("key", "").strip().lower() - - if not key or user_token == "": - return Response( - {"error": "User token and key are required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - ri = redis_instance() - - if ri.exists(key): - data = json.loads(ri.get(key)) - - token = data["token"] - email = data["email"] - - if str(token) == str(user_token): - user = User.objects.get(email=email) - # Send event - auth_events.delay( - user=user.id, - email=email, - user_agent=request.META.get("HTTP_USER_AGENT"), - ip=request.META.get("REMOTE_ADDR"), - event_name="Sign in", - medium="Magic link", - first_time=False, - ) - - user.is_active = True - user.is_email_verified = True - user.last_active = timezone.now() - user.last_login_time = timezone.now() - user.last_login_ip = request.META.get("REMOTE_ADDR") - user.last_login_uagent = request.META.get("HTTP_USER_AGENT") - user.token_updated_at = timezone.now() - user.save() - - # Check if user has any accepted invites for workspace and add them to workspace - workspace_member_invites = ( - WorkspaceMemberInvite.objects.filter( - email=user.email, accepted=True - ) - ) - - WorkspaceMember.objects.bulk_create( - [ - WorkspaceMember( - workspace_id=workspace_member_invite.workspace_id, - member=user, - role=workspace_member_invite.role, - ) - for workspace_member_invite in workspace_member_invites - ], - ignore_conflicts=True, - ) - - # Check if user has any project invites - project_member_invites = ProjectMemberInvite.objects.filter( - email=user.email, accepted=True - ) - - # Add user to workspace - WorkspaceMember.objects.bulk_create( - [ - WorkspaceMember( - workspace_id=project_member_invite.workspace_id, - role=( - project_member_invite.role - if project_member_invite.role in [5, 10, 15] - else 15 - ), - member=user, - created_by_id=project_member_invite.created_by_id, - ) - for project_member_invite in project_member_invites - ], - ignore_conflicts=True, - ) - - # Now add the users to project - ProjectMember.objects.bulk_create( - [ - ProjectMember( - workspace_id=project_member_invite.workspace_id, - role=( - project_member_invite.role - if project_member_invite.role in [5, 10, 15] - else 15 - ), - member=user, - created_by_id=project_member_invite.created_by_id, - ) - for project_member_invite in project_member_invites - ], - ignore_conflicts=True, - ) - - # Delete all the invites - workspace_member_invites.delete() - project_member_invites.delete() - - access_token, refresh_token = get_tokens_for_user(user) - data = { - "access_token": access_token, - "refresh_token": refresh_token, - } - - return Response(data, status=status.HTTP_200_OK) - - else: - return Response( - { - "error": "Your login code was incorrect. Please try again." - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - else: - return Response( - {"error": "The magic code/link has expired please try again"}, - status=status.HTTP_400_BAD_REQUEST, - ) diff --git a/apiserver/plane/app/views/base.py b/apiserver/plane/app/views/base.py index c14540945..8f21f5fe1 100644 --- a/apiserver/plane/app/views/base.py +++ b/apiserver/plane/app/views/base.py @@ -19,6 +19,7 @@ from rest_framework.views import APIView from rest_framework.viewsets import ModelViewSet # Module imports +from plane.authentication.session import BaseSessionAuthentication from plane.utils.exception_logger import log_exception from plane.utils.paginator import BasePaginator @@ -49,6 +50,10 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): SearchFilter, ) + authentication_classes = [ + BaseSessionAuthentication, + ] + filterset_fields = [] search_fields = [] @@ -161,6 +166,10 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): SearchFilter, ) + authentication_classes = [ + BaseSessionAuthentication, + ] + filterset_fields = [] search_fields = [] diff --git a/apiserver/plane/app/views/config.py b/apiserver/plane/app/views/config.py deleted file mode 100644 index 066f606b9..000000000 --- a/apiserver/plane/app/views/config.py +++ /dev/null @@ -1,248 +0,0 @@ -# Python imports -import os - -# Django imports - -# Third party imports -from rest_framework.permissions import AllowAny -from rest_framework import status -from rest_framework.response import Response - -# Module imports -from .base import BaseAPIView -from plane.license.utils.instance_value import get_configuration_value -from plane.utils.cache import cache_response - -class ConfigurationEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] - - @cache_response(60 * 60 * 2, user=False) - def get(self, request): - # Get all the configuration - ( - GOOGLE_CLIENT_ID, - GITHUB_CLIENT_ID, - GITHUB_APP_NAME, - EMAIL_HOST_USER, - EMAIL_HOST_PASSWORD, - ENABLE_MAGIC_LINK_LOGIN, - ENABLE_EMAIL_PASSWORD, - SLACK_CLIENT_ID, - POSTHOG_API_KEY, - POSTHOG_HOST, - UNSPLASH_ACCESS_KEY, - OPENAI_API_KEY, - ) = get_configuration_value( - [ - { - "key": "GOOGLE_CLIENT_ID", - "default": os.environ.get("GOOGLE_CLIENT_ID", None), - }, - { - "key": "GITHUB_CLIENT_ID", - "default": os.environ.get("GITHUB_CLIENT_ID", None), - }, - { - "key": "GITHUB_APP_NAME", - "default": os.environ.get("GITHUB_APP_NAME", None), - }, - { - "key": "EMAIL_HOST_USER", - "default": os.environ.get("EMAIL_HOST_USER", None), - }, - { - "key": "EMAIL_HOST_PASSWORD", - "default": os.environ.get("EMAIL_HOST_PASSWORD", None), - }, - { - "key": "ENABLE_MAGIC_LINK_LOGIN", - "default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "1"), - }, - { - "key": "ENABLE_EMAIL_PASSWORD", - "default": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"), - }, - { - "key": "SLACK_CLIENT_ID", - "default": os.environ.get("SLACK_CLIENT_ID", None), - }, - { - "key": "POSTHOG_API_KEY", - "default": os.environ.get("POSTHOG_API_KEY", None), - }, - { - "key": "POSTHOG_HOST", - "default": os.environ.get("POSTHOG_HOST", None), - }, - { - "key": "UNSPLASH_ACCESS_KEY", - "default": os.environ.get("UNSPLASH_ACCESS_KEY", "1"), - }, - { - "key": "OPENAI_API_KEY", - "default": os.environ.get("OPENAI_API_KEY", "1"), - }, - ] - ) - - data = {} - # Authentication - data["google_client_id"] = ( - GOOGLE_CLIENT_ID - if GOOGLE_CLIENT_ID and GOOGLE_CLIENT_ID != '""' - else None - ) - data["github_client_id"] = ( - GITHUB_CLIENT_ID - if GITHUB_CLIENT_ID and GITHUB_CLIENT_ID != '""' - else None - ) - data["github_app_name"] = GITHUB_APP_NAME - data["magic_login"] = ( - bool(EMAIL_HOST_USER) and bool(EMAIL_HOST_PASSWORD) - ) and ENABLE_MAGIC_LINK_LOGIN == "1" - - data["email_password_login"] = ENABLE_EMAIL_PASSWORD == "1" - # Slack client - data["slack_client_id"] = SLACK_CLIENT_ID - - # Posthog - data["posthog_api_key"] = POSTHOG_API_KEY - data["posthog_host"] = POSTHOG_HOST - - # Unsplash - data["has_unsplash_configured"] = bool(UNSPLASH_ACCESS_KEY) - - # Open AI settings - data["has_openai_configured"] = bool(OPENAI_API_KEY) - - # File size settings - data["file_size_limit"] = float( - os.environ.get("FILE_SIZE_LIMIT", 5242880) - ) - - # is smtp configured - data["is_smtp_configured"] = bool(EMAIL_HOST_USER) and bool( - EMAIL_HOST_PASSWORD - ) - - return Response(data, status=status.HTTP_200_OK) - - -class MobileConfigurationEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] - - @cache_response(60 * 60 * 2, user=False) - def get(self, request): - ( - GOOGLE_CLIENT_ID, - GOOGLE_SERVER_CLIENT_ID, - GOOGLE_IOS_CLIENT_ID, - EMAIL_HOST_USER, - EMAIL_HOST_PASSWORD, - ENABLE_MAGIC_LINK_LOGIN, - ENABLE_EMAIL_PASSWORD, - POSTHOG_API_KEY, - POSTHOG_HOST, - UNSPLASH_ACCESS_KEY, - OPENAI_API_KEY, - ) = get_configuration_value( - [ - { - "key": "GOOGLE_CLIENT_ID", - "default": os.environ.get("GOOGLE_CLIENT_ID", None), - }, - { - "key": "GOOGLE_SERVER_CLIENT_ID", - "default": os.environ.get("GOOGLE_SERVER_CLIENT_ID", None), - }, - { - "key": "GOOGLE_IOS_CLIENT_ID", - "default": os.environ.get("GOOGLE_IOS_CLIENT_ID", None), - }, - { - "key": "EMAIL_HOST_USER", - "default": os.environ.get("EMAIL_HOST_USER", None), - }, - { - "key": "EMAIL_HOST_PASSWORD", - "default": os.environ.get("EMAIL_HOST_PASSWORD", None), - }, - { - "key": "ENABLE_MAGIC_LINK_LOGIN", - "default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "1"), - }, - { - "key": "ENABLE_EMAIL_PASSWORD", - "default": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"), - }, - { - "key": "POSTHOG_API_KEY", - "default": os.environ.get("POSTHOG_API_KEY", None), - }, - { - "key": "POSTHOG_HOST", - "default": os.environ.get("POSTHOG_HOST", None), - }, - { - "key": "UNSPLASH_ACCESS_KEY", - "default": os.environ.get("UNSPLASH_ACCESS_KEY", "1"), - }, - { - "key": "OPENAI_API_KEY", - "default": os.environ.get("OPENAI_API_KEY", "1"), - }, - ] - ) - data = {} - # Authentication - data["google_client_id"] = ( - GOOGLE_CLIENT_ID - if GOOGLE_CLIENT_ID and GOOGLE_CLIENT_ID != '""' - else None - ) - data["google_server_client_id"] = ( - GOOGLE_SERVER_CLIENT_ID - if GOOGLE_SERVER_CLIENT_ID and GOOGLE_SERVER_CLIENT_ID != '""' - else None - ) - data["google_ios_client_id"] = ( - (GOOGLE_IOS_CLIENT_ID)[::-1] - if GOOGLE_IOS_CLIENT_ID is not None - else None - ) - # Posthog - data["posthog_api_key"] = POSTHOG_API_KEY - data["posthog_host"] = POSTHOG_HOST - - data["magic_login"] = ( - bool(EMAIL_HOST_USER) and bool(EMAIL_HOST_PASSWORD) - ) and ENABLE_MAGIC_LINK_LOGIN == "1" - - data["email_password_login"] = ENABLE_EMAIL_PASSWORD == "1" - - # Posthog - data["posthog_api_key"] = POSTHOG_API_KEY - data["posthog_host"] = POSTHOG_HOST - - # Unsplash - data["has_unsplash_configured"] = bool(UNSPLASH_ACCESS_KEY) - - # Open AI settings - data["has_openai_configured"] = bool(OPENAI_API_KEY) - - # File size settings - data["file_size_limit"] = float( - os.environ.get("FILE_SIZE_LIMIT", 5242880) - ) - - # is smtp configured - data["is_smtp_configured"] = not ( - bool(EMAIL_HOST_USER) and bool(EMAIL_HOST_PASSWORD) - ) - - return Response(data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/oauth.py b/apiserver/plane/app/views/oauth.py deleted file mode 100644 index 48630175a..000000000 --- a/apiserver/plane/app/views/oauth.py +++ /dev/null @@ -1,458 +0,0 @@ -# Python imports -import uuid -import requests -import os - -# Django imports -from django.utils import timezone - -# Third Party modules -from rest_framework.response import Response -from rest_framework import exceptions -from rest_framework.permissions import AllowAny -from rest_framework_simplejwt.tokens import RefreshToken -from rest_framework import status -from sentry_sdk import capture_exception - -# sso authentication -from google.oauth2 import id_token -from google.auth.transport import requests as google_auth_request - -# Module imports -from plane.db.models import ( - SocialLoginConnection, - User, - WorkspaceMemberInvite, - WorkspaceMember, - ProjectMemberInvite, - ProjectMember, -) -from plane.bgtasks.event_tracking_task import auth_events -from .base import BaseAPIView -from plane.license.models import Instance -from plane.license.utils.instance_value import get_configuration_value - - -def get_tokens_for_user(user): - refresh = RefreshToken.for_user(user) - return ( - str(refresh.access_token), - str(refresh), - ) - - -def validate_google_token(token, client_id): - try: - id_info = id_token.verify_oauth2_token( - token, google_auth_request.Request(), client_id - ) - email = id_info.get("email") - first_name = id_info.get("given_name") - last_name = id_info.get("family_name", "") - data = { - "email": email, - "first_name": first_name, - "last_name": last_name, - } - return data - except Exception as e: - capture_exception(e) - raise exceptions.AuthenticationFailed("Error with Google connection.") - - -def get_access_token(request_token: str, client_id: str) -> str: - """Obtain the request token from github. - Given the client id, client secret and request issued out by GitHub, this method - should give back an access token - Parameters - ---------- - CLIENT_ID: str - A string representing the client id issued out by github - CLIENT_SECRET: str - A string representing the client secret issued out by github - request_token: str - A string representing the request token issued out by github - Throws - ------ - ValueError: - if CLIENT_ID or CLIENT_SECRET or request_token is empty or not a string - Returns - ------- - access_token: str - A string representing the access token issued out by github - """ - - if not request_token: - raise ValueError("The request token has to be supplied!") - - (CLIENT_SECRET,) = get_configuration_value( - [ - { - "key": "GITHUB_CLIENT_SECRET", - "default": os.environ.get("GITHUB_CLIENT_SECRET", None), - }, - ] - ) - - url = f"https://github.com/login/oauth/access_token?client_id={client_id}&client_secret={CLIENT_SECRET}&code={request_token}" - headers = {"accept": "application/json"} - - res = requests.post(url, headers=headers) - - data = res.json() - access_token = data["access_token"] - - return access_token - - -def get_user_data(access_token: str) -> dict: - """ - Obtain the user data from github. - Given the access token, this method should give back the user data - """ - if not access_token: - raise ValueError("The request token has to be supplied!") - if not isinstance(access_token, str): - raise ValueError("The request token has to be a string!") - - access_token = "token " + access_token - url = "https://api.github.com/user" - headers = {"Authorization": access_token} - - resp = requests.get(url=url, headers=headers) - - user_data = resp.json() - - response = requests.get( - url="https://api.github.com/user/emails", headers=headers - ).json() - - _ = [ - user_data.update({"email": item.get("email")}) - for item in response - if item.get("primary") is True - ] - - return user_data - - -class OauthEndpoint(BaseAPIView): - permission_classes = [AllowAny] - - def post(self, request): - try: - # Check if instance is registered or not - instance = Instance.objects.first() - if instance is None and not instance.is_setup_done: - return Response( - {"error": "Instance is not configured"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - medium = request.data.get("medium", False) - id_token = request.data.get("credential", False) - client_id = request.data.get("clientId", False) - - GOOGLE_CLIENT_ID, GITHUB_CLIENT_ID = get_configuration_value( - [ - { - "key": "GOOGLE_CLIENT_ID", - "default": os.environ.get("GOOGLE_CLIENT_ID"), - }, - { - "key": "GITHUB_CLIENT_ID", - "default": os.environ.get("GITHUB_CLIENT_ID"), - }, - ] - ) - - if not medium or not id_token: - return Response( - { - "error": "Something went wrong. Please try again later or contact the support team." - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - if medium == "google": - if not GOOGLE_CLIENT_ID: - return Response( - {"error": "Google login is not configured"}, - status=status.HTTP_400_BAD_REQUEST, - ) - data = validate_google_token(id_token, client_id) - - if medium == "github": - if not GITHUB_CLIENT_ID: - return Response( - {"error": "Github login is not configured"}, - status=status.HTTP_400_BAD_REQUEST, - ) - access_token = get_access_token(id_token, client_id) - data = get_user_data(access_token) - - email = data.get("email", None) - if email is None: - return Response( - { - "error": "Something went wrong. Please try again later or contact the support team." - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - if "@" in email: - user = User.objects.get(email=email) - email = data["email"] - mobile_number = uuid.uuid4().hex - email_verified = True - else: - return Response( - { - "error": "Something went wrong. Please try again later or contact the support team." - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - user.is_active = True - user.last_active = timezone.now() - user.last_login_time = timezone.now() - user.last_login_ip = request.META.get("REMOTE_ADDR") - user.last_login_medium = "oauth" - user.last_login_uagent = request.META.get("HTTP_USER_AGENT") - user.is_email_verified = email_verified - user.save() - - # Check if user has any accepted invites for workspace and add them to workspace - workspace_member_invites = WorkspaceMemberInvite.objects.filter( - email=user.email, accepted=True - ) - - WorkspaceMember.objects.bulk_create( - [ - WorkspaceMember( - workspace_id=workspace_member_invite.workspace_id, - member=user, - role=workspace_member_invite.role, - ) - for workspace_member_invite in workspace_member_invites - ], - ignore_conflicts=True, - ) - - # Check if user has any project invites - project_member_invites = ProjectMemberInvite.objects.filter( - email=user.email, accepted=True - ) - - # Add user to workspace - WorkspaceMember.objects.bulk_create( - [ - WorkspaceMember( - workspace_id=project_member_invite.workspace_id, - role=( - project_member_invite.role - if project_member_invite.role in [5, 10, 15] - else 15 - ), - member=user, - created_by_id=project_member_invite.created_by_id, - ) - for project_member_invite in project_member_invites - ], - ignore_conflicts=True, - ) - - # Now add the users to project - ProjectMember.objects.bulk_create( - [ - ProjectMember( - workspace_id=project_member_invite.workspace_id, - role=( - project_member_invite.role - if project_member_invite.role in [5, 10, 15] - else 15 - ), - member=user, - created_by_id=project_member_invite.created_by_id, - ) - for project_member_invite in project_member_invites - ], - ignore_conflicts=True, - ) - # Delete all the invites - workspace_member_invites.delete() - project_member_invites.delete() - - SocialLoginConnection.objects.update_or_create( - medium=medium, - extra_data={}, - user=user, - defaults={ - "token_data": {"id_token": id_token}, - "last_login_at": timezone.now(), - }, - ) - - # Send event - auth_events.delay( - user=user.id, - email=email, - user_agent=request.META.get("HTTP_USER_AGENT"), - ip=request.META.get("REMOTE_ADDR"), - event_name="Sign in", - medium=medium.upper(), - first_time=False, - ) - - access_token, refresh_token = get_tokens_for_user(user) - - data = { - "access_token": access_token, - "refresh_token": refresh_token, - } - return Response(data, status=status.HTTP_200_OK) - - except User.DoesNotExist: - (ENABLE_SIGNUP,) = get_configuration_value( - [ - { - "key": "ENABLE_SIGNUP", - "default": os.environ.get("ENABLE_SIGNUP", "0"), - } - ] - ) - if ( - ENABLE_SIGNUP == "0" - and not WorkspaceMemberInvite.objects.filter( - email=email, - ).exists() - ): - return Response( - { - "error": "New account creation is disabled. Please contact your site administrator" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - username = uuid.uuid4().hex - - if "@" in email: - email = data["email"] - mobile_number = uuid.uuid4().hex - email_verified = True - else: - return Response( - { - "error": "Something went wrong. Please try again later or contact the support team." - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - user = User.objects.create( - username=username, - email=email, - mobile_number=mobile_number, - first_name=data.get("first_name", ""), - last_name=data.get("last_name", ""), - is_email_verified=email_verified, - is_password_autoset=True, - ) - - user.set_password(uuid.uuid4().hex) - user.last_active = timezone.now() - user.last_login_time = timezone.now() - user.last_login_ip = request.META.get("REMOTE_ADDR") - user.last_login_medium = "oauth" - user.last_login_uagent = request.META.get("HTTP_USER_AGENT") - user.token_updated_at = timezone.now() - user.save() - - # Check if user has any accepted invites for workspace and add them to workspace - workspace_member_invites = WorkspaceMemberInvite.objects.filter( - email=user.email, accepted=True - ) - - WorkspaceMember.objects.bulk_create( - [ - WorkspaceMember( - workspace_id=workspace_member_invite.workspace_id, - member=user, - role=workspace_member_invite.role, - ) - for workspace_member_invite in workspace_member_invites - ], - ignore_conflicts=True, - ) - - # Check if user has any project invites - project_member_invites = ProjectMemberInvite.objects.filter( - email=user.email, accepted=True - ) - - # Add user to workspace - WorkspaceMember.objects.bulk_create( - [ - WorkspaceMember( - workspace_id=project_member_invite.workspace_id, - role=( - project_member_invite.role - if project_member_invite.role in [5, 10, 15] - else 15 - ), - member=user, - created_by_id=project_member_invite.created_by_id, - ) - for project_member_invite in project_member_invites - ], - ignore_conflicts=True, - ) - - # Now add the users to project - ProjectMember.objects.bulk_create( - [ - ProjectMember( - workspace_id=project_member_invite.workspace_id, - role=( - project_member_invite.role - if project_member_invite.role in [5, 10, 15] - else 15 - ), - member=user, - created_by_id=project_member_invite.created_by_id, - ) - for project_member_invite in project_member_invites - ], - ignore_conflicts=True, - ) - # Delete all the invites - workspace_member_invites.delete() - project_member_invites.delete() - - # Send event - auth_events.delay( - user=user.id, - email=email, - user_agent=request.META.get("HTTP_USER_AGENT"), - ip=request.META.get("REMOTE_ADDR"), - event_name="Sign up", - medium=medium.upper(), - first_time=True, - ) - - SocialLoginConnection.objects.update_or_create( - medium=medium, - extra_data={}, - user=user, - defaults={ - "token_data": {"id_token": id_token}, - "last_login_at": timezone.now(), - }, - ) - - access_token, refresh_token = get_tokens_for_user(user) - data = { - "access_token": access_token, - "refresh_token": refresh_token, - } - - return Response(data, status=status.HTTP_201_CREATED) diff --git a/apiserver/plane/app/views/user/base.py b/apiserver/plane/app/views/user/base.py index 487e365cd..60823a5a7 100644 --- a/apiserver/plane/app/views/user/base.py +++ b/apiserver/plane/app/views/user/base.py @@ -7,13 +7,22 @@ from rest_framework.response import Response # Module imports from plane.app.serializers import ( + AccountSerializer, IssueActivitySerializer, + ProfileSerializer, UserMeSerializer, UserMeSettingsSerializer, UserSerializer, ) from plane.app.views.base import BaseAPIView, BaseViewSet -from plane.db.models import IssueActivity, ProjectMember, User, WorkspaceMember +from plane.db.models import ( + Account, + IssueActivity, + Profile, + ProjectMember, + User, + WorkspaceMember, +) from plane.license.models import Instance, InstanceAdmin from plane.utils.cache import cache_response, invalidate_cache from plane.utils.paginator import BasePaginator @@ -143,15 +152,20 @@ class UserEndpoint(BaseViewSet): # Deactivate the user user.is_active = False - user.last_workspace_id = None - user.is_tour_completed = False - user.is_onboarded = False - user.onboarding_step = { + + # Profile updates + profile = Profile.objects.get(user=user) + + profile.last_workspace_id = None + profile.is_tour_completed = False + profile.is_onboarded = False + profile.onboarding_step = { "workspace_join": False, "profile_complete": False, "workspace_create": False, "workspace_invite": False, } + profile.save() user.save() return Response(status=status.HTTP_204_NO_CONTENT) @@ -160,9 +174,9 @@ class UpdateUserOnBoardedEndpoint(BaseAPIView): @invalidate_cache(path="/api/users/me/") def patch(self, request): - user = User.objects.get(pk=request.user.id, is_active=True) - user.is_onboarded = request.data.get("is_onboarded", False) - user.save() + profile = Profile.objects.get(user_id=request.user.id) + profile.is_onboarded = request.data.get("is_onboarded", False) + profile.save() return Response( {"message": "Updated successfully"}, status=status.HTTP_200_OK ) @@ -172,9 +186,11 @@ class UpdateUserTourCompletedEndpoint(BaseAPIView): @invalidate_cache(path="/api/users/me/") def patch(self, request): - user = User.objects.get(pk=request.user.id, is_active=True) - user.is_tour_completed = request.data.get("is_tour_completed", False) - user.save() + profile = Profile.objects.get(user_id=request.user.id) + profile.is_tour_completed = request.data.get( + "is_tour_completed", False + ) + profile.save() return Response( {"message": "Updated successfully"}, status=status.HTTP_200_OK ) @@ -194,3 +210,41 @@ class UserActivityEndpoint(BaseAPIView, BasePaginator): issue_activities, many=True ).data, ) + + +class AccountEndpoint(BaseAPIView): + + def get(self, request, pk=None): + if pk: + account = Account.objects.get(pk=pk, user=request.user) + serializer = AccountSerializer(account) + return Response(serializer.data, status=status.HTTP_200_OK) + + account = Account.objects.filter(user=request.user) + serializer = AccountSerializer(account, many=True) + return Response( + serializer.data, + status=status.HTTP_200_OK, + ) + + def delete(self, request, pk): + account = Account.objects.get(pk=pk, user=request.user) + account.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ProfileEndpoint(BaseAPIView): + def get(self, request): + profile = Profile.objects.get(user=request.user) + serializer = ProfileSerializer(profile) + return Response(serializer.data, status=status.HTTP_200_OK) + + def patch(self, request): + profile = Profile.objects.get(user=request.user) + serializer = ProfileSerializer( + profile, data=request.data, partial=True + ) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/authentication/__init__.py b/apiserver/plane/authentication/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/authentication/adapter/__init__.py b/apiserver/plane/authentication/adapter/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/authentication/adapter/base.py b/apiserver/plane/authentication/adapter/base.py new file mode 100644 index 000000000..97d0bf908 --- /dev/null +++ b/apiserver/plane/authentication/adapter/base.py @@ -0,0 +1,115 @@ +# Python imports +import os +import uuid + +# Django imports +from django.utils import timezone + +# Third party imports +from zxcvbn import zxcvbn + +# Module imports +from plane.db.models import ( + Profile, + User, + WorkspaceMemberInvite, +) +from plane.license.utils.instance_value import get_configuration_value +from .error import AuthenticationException, AUTHENTICATION_ERROR_CODES + + +class Adapter: + """Common interface for all auth providers""" + + def __init__(self, request, provider): + self.request = request + self.provider = provider + self.token_data = None + self.user_data = None + + def get_user_token(self, data, headers=None): + raise NotImplementedError + + def get_user_response(self): + raise NotImplementedError + + def set_token_data(self, data): + self.token_data = data + + def set_user_data(self, data): + self.user_data = data + + def create_update_account(self, user): + raise NotImplementedError + + def authenticate(self): + raise NotImplementedError + + def complete_login_or_signup(self): + email = self.user_data.get("email") + user = User.objects.filter(email=email).first() + + if not user: + # New user + (ENABLE_SIGNUP,) = get_configuration_value( + [ + { + "key": "ENABLE_SIGNUP", + "default": os.environ.get("ENABLE_SIGNUP", "1"), + }, + ] + ) + if ( + ENABLE_SIGNUP == "0" + and not WorkspaceMemberInvite.objects.filter( + email=email, + ).exists() + ): + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["SIGNUP_DISABLED"], + error_message="SIGNUP_DISABLED", + payload={"email": email}, + ) + user = User(email=email, username=uuid.uuid4().hex) + + if self.user_data.get("user").get("is_password_autoset"): + user.set_password(uuid.uuid4().hex) + user.is_password_autoset = True + user.is_email_verified = True + else: + # Validate password + results = zxcvbn(self.code) + if results["score"] < 3: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INVALID_PASSWORD" + ], + error_message="INVALID_PASSWORD", + payload={"email": email}, + ) + + user.set_password(self.code) + user.is_password_autoset = False + + avatar = self.user_data.get("user", {}).get("avatar", "") + first_name = self.user_data.get("user", {}).get("first_name", "") + last_name = self.user_data.get("user", {}).get("last_name", "") + user.avatar = avatar if avatar else "" + user.first_name = first_name if first_name else "" + user.last_name = last_name if last_name else "" + user.save() + Profile.objects.create(user=user) + + # Update user details + user.last_login_medium = self.provider + user.last_active = timezone.now() + user.last_login_time = timezone.now() + user.last_login_ip = self.request.META.get("REMOTE_ADDR") + user.last_login_uagent = self.request.META.get("HTTP_USER_AGENT") + user.token_updated_at = timezone.now() + user.save() + + if self.token_data: + self.create_update_account(user=user) + + return user diff --git a/apiserver/plane/authentication/adapter/credential.py b/apiserver/plane/authentication/adapter/credential.py new file mode 100644 index 000000000..b1fd75d02 --- /dev/null +++ b/apiserver/plane/authentication/adapter/credential.py @@ -0,0 +1,14 @@ +from plane.authentication.adapter.base import Adapter + + +class CredentialAdapter(Adapter): + """Common interface for all credential providers""" + + def __init__(self, request, provider): + super().__init__(request, provider) + self.request = request + self.provider = provider + + def authenticate(self): + self.set_user_data() + return self.complete_login_or_signup() diff --git a/apiserver/plane/authentication/adapter/error.py b/apiserver/plane/authentication/adapter/error.py new file mode 100644 index 000000000..4b975939d --- /dev/null +++ b/apiserver/plane/authentication/adapter/error.py @@ -0,0 +1,71 @@ +AUTHENTICATION_ERROR_CODES = { + # Global + "INSTANCE_NOT_CONFIGURED": 5000, + "INVALID_EMAIL": 5012, + "EMAIL_REQUIRED": 5013, + "SIGNUP_DISABLED": 5001, + # Password strength + "INVALID_PASSWORD": 5002, + "SMTP_NOT_CONFIGURED": 5007, + # Sign Up + "USER_ALREADY_EXIST": 5003, + "AUTHENTICATION_FAILED_SIGN_UP": 5006, + "REQUIRED_EMAIL_PASSWORD_SIGN_UP": 5015, + "INVALID_EMAIL_SIGN_UP": 5017, + "INVALID_EMAIL_MAGIC_SIGN_UP": 5019, + "MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED": 5023, + # Sign In + "USER_DOES_NOT_EXIST": 5004, + "AUTHENTICATION_FAILED_SIGN_IN": 5005, + "REQUIRED_EMAIL_PASSWORD_SIGN_IN": 5014, + "INVALID_EMAIL_SIGN_IN": 5016, + "INVALID_EMAIL_MAGIC_SIGN_IN": 5018, + "MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED": 5022, + # Both Sign in and Sign up + "INVALID_MAGIC_CODE": 5008, + "EXPIRED_MAGIC_CODE": 5009, + # Oauth + "GOOGLE_NOT_CONFIGURED": 5010, + "GITHUB_NOT_CONFIGURED": 5011, + "GOOGLE_OAUTH_PROVIDER_ERROR": 5021, + "GITHUB_OAUTH_PROVIDER_ERROR": 5020, + # Reset Password + "INVALID_PASSWORD_TOKEN": 5024, + "EXPIRED_PASSWORD_TOKEN": 5025, + # Change password + "INCORRECT_OLD_PASSWORD": 5026, + "INVALID_NEW_PASSWORD": 5027, + # set passowrd + "PASSWORD_ALREADY_SET": 5028, + # Admin + "ADMIN_ALREADY_EXIST": 5029, + "REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME": 5030, + "INVALID_ADMIN_EMAIL": 5031, + "INVALID_ADMIN_PASSWORD": 5032, + "REQUIRED_ADMIN_EMAIL_PASSWORD": 5033, + "ADMIN_AUTHENTICATION_FAILED": 5034, + "ADMIN_USER_ALREADY_EXIST": 5035, + "ADMIN_USER_DOES_NOT_EXIST": 5036, +} + + +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..2086046a4 --- /dev/null +++ b/apiserver/plane/authentication/adapter/exception.py @@ -0,0 +1,18 @@ +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): + # Return 403 if the users me api fails + request = context["request"] + if request.path == "/api/users/me/": + response.status_code = 403 + # else return 401 + else: + response.status_code = 401 + + return response diff --git a/apiserver/plane/authentication/adapter/oauth.py b/apiserver/plane/authentication/adapter/oauth.py new file mode 100644 index 000000000..91cab7c5f --- /dev/null +++ b/apiserver/plane/authentication/adapter/oauth.py @@ -0,0 +1,88 @@ +# Python imports +import requests + +# Django imports +from django.utils import timezone + +# Module imports +from plane.db.models import Account + +from .base import Adapter + + +class OauthAdapter(Adapter): + def __init__( + self, + request, + provider, + client_id, + scope, + redirect_uri, + auth_url, + token_url, + userinfo_url, + client_secret=None, + code=None, + ): + super().__init__(request, provider) + self.client_id = client_id + self.scope = scope + self.redirect_uri = redirect_uri + self.auth_url = auth_url + self.token_url = token_url + self.userinfo_url = userinfo_url + self.client_secret = client_secret + self.code = code + + def get_auth_url(self): + return self.auth_url + + def get_token_url(self): + return self.token_url + + def get_user_info_url(self): + return self.userinfo_url + + def authenticate(self): + self.set_token_data() + self.set_user_data() + return self.complete_login_or_signup() + + def get_user_token(self, data, headers=None): + headers = headers or {} + response = requests.post( + self.get_token_url(), data=data, headers=headers + ) + response.raise_for_status() + return response.json() + + def get_user_response(self): + headers = { + "Authorization": f"Bearer {self.token_data.get('access_token')}" + } + response = requests.get(self.get_user_info_url(), headers=headers) + response.raise_for_status() + return response.json() + + def set_user_data(self, data): + self.user_data = data + + def create_update_account(self, user): + account, created = Account.objects.update_or_create( + user=user, + provider=self.provider, + defaults={ + "provider_account_id": self.user_data.get("user").get( + "provider_id" + ), + "access_token": self.token_data.get("access_token"), + "refresh_token": self.token_data.get("refresh_token", None), + "access_token_expired_at": self.token_data.get( + "access_token_expired_at" + ), + "refresh_token_expired_at": self.token_data.get( + "refresh_token_expired_at" + ), + "last_connected_at": timezone.now(), + }, + ) diff --git a/apiserver/plane/authentication/apps.py b/apiserver/plane/authentication/apps.py new file mode 100644 index 000000000..cf5cdca1c --- /dev/null +++ b/apiserver/plane/authentication/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AuthConfig(AppConfig): + name = "plane.authentication" diff --git a/apiserver/plane/authentication/middleware/__init__.py b/apiserver/plane/authentication/middleware/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/authentication/middleware/session.py b/apiserver/plane/authentication/middleware/session.py new file mode 100644 index 000000000..2bb62b881 --- /dev/null +++ b/apiserver/plane/authentication/middleware/session.py @@ -0,0 +1,94 @@ +import time +from importlib import import_module + +from django.conf import settings +from django.contrib.sessions.backends.base import UpdateError +from django.contrib.sessions.exceptions import SessionInterrupted +from django.utils.cache import patch_vary_headers +from django.utils.deprecation import MiddlewareMixin +from django.utils.http import http_date + + +class SessionMiddleware(MiddlewareMixin): + def __init__(self, get_response): + super().__init__(get_response) + engine = import_module(settings.SESSION_ENGINE) + self.SessionStore = engine.SessionStore + + def process_request(self, request): + if "instances" in request.path: + session_key = request.COOKIES.get( + settings.ADMIN_SESSION_COOKIE_NAME + ) + else: + session_key = request.COOKIES.get(settings.SESSION_COOKIE_NAME) + request.session = self.SessionStore(session_key) + + def process_response(self, request, response): + """ + If request.session was modified, or if the configuration is to save the + session every time, save the changes and set a session cookie or delete + the session cookie if the session has been emptied. + """ + try: + accessed = request.session.accessed + modified = request.session.modified + empty = request.session.is_empty() + except AttributeError: + return response + # First check if we need to delete this cookie. + # The session should be deleted only if the session is entirely empty. + is_admin_path = "instances" in request.path + cookie_name = ( + settings.ADMIN_SESSION_COOKIE_NAME + if is_admin_path + else settings.SESSION_COOKIE_NAME + ) + + if cookie_name in request.COOKIES and empty: + response.delete_cookie( + cookie_name, + path=settings.SESSION_COOKIE_PATH, + domain=settings.SESSION_COOKIE_DOMAIN, + samesite=settings.SESSION_COOKIE_SAMESITE, + ) + patch_vary_headers(response, ("Cookie",)) + else: + if accessed: + patch_vary_headers(response, ("Cookie",)) + if (modified or settings.SESSION_SAVE_EVERY_REQUEST) and not empty: + if request.session.get_expire_at_browser_close(): + max_age = None + expires = None + else: + # Use different max_age based on whether it's an admin cookie + if is_admin_path: + max_age = settings.ADMIN_SESSION_COOKIE_AGE + else: + max_age = request.session.get_expiry_age() + + expires_time = time.time() + max_age + expires = http_date(expires_time) + + # Save the session data and refresh the client cookie. + if response.status_code < 500: + try: + request.session.save() + except UpdateError: + raise SessionInterrupted( + "The request's session was deleted before the " + "request completed. The user may have logged " + "out in a concurrent request, for example." + ) + response.set_cookie( + cookie_name, + request.session.session_key, + max_age=max_age, + expires=expires, + domain=settings.SESSION_COOKIE_DOMAIN, + path=settings.SESSION_COOKIE_PATH, + secure=settings.SESSION_COOKIE_SECURE or None, + httponly=settings.SESSION_COOKIE_HTTPONLY or None, + samesite=settings.SESSION_COOKIE_SAMESITE, + ) + return response diff --git a/apiserver/plane/authentication/provider/__init__.py b/apiserver/plane/authentication/provider/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/authentication/provider/credentials/__init__.py b/apiserver/plane/authentication/provider/credentials/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/authentication/provider/credentials/email.py b/apiserver/plane/authentication/provider/credentials/email.py new file mode 100644 index 000000000..430c6db2a --- /dev/null +++ b/apiserver/plane/authentication/provider/credentials/email.py @@ -0,0 +1,97 @@ +# Module imports +from plane.authentication.adapter.credential import CredentialAdapter +from plane.db.models import User +from plane.authentication.adapter.error import ( + AUTHENTICATION_ERROR_CODES, + AuthenticationException, +) + + +class EmailProvider(CredentialAdapter): + + provider = "email" + + def __init__( + self, + request, + key=None, + code=None, + is_signup=False, + ): + super().__init__(request, self.provider) + self.key = key + self.code = code + self.is_signup = is_signup + + def set_user_data(self): + if self.is_signup: + # Check if the user already exists + if User.objects.filter(email=self.key).exists(): + raise AuthenticationException( + error_message="USER_ALREADY_EXIST", + error_code=AUTHENTICATION_ERROR_CODES[ + "USER_ALREADY_EXIST" + ], + ) + + super().set_user_data( + { + "email": self.key, + "user": { + "avatar": "", + "first_name": "", + "last_name": "", + "provider_id": "", + "is_password_autoset": False, + }, + } + ) + return + else: + user = User.objects.filter( + email=self.key, + ).first() + + # User does not exists + if not user: + raise AuthenticationException( + error_message="USER_DOES_NOT_EXIST", + error_code=AUTHENTICATION_ERROR_CODES[ + "USER_DOES_NOT_EXIST" + ], + payload={ + "email": self.key, + }, + ) + + # Check user password + if not user.check_password(self.code): + raise AuthenticationException( + error_message=( + "AUTHENTICATION_FAILED_SIGN_UP" + if self.is_signup + else "AUTHENTICATION_FAILED_SIGN_IN" + ), + error_code=AUTHENTICATION_ERROR_CODES[ + ( + "AUTHENTICATION_FAILED_SIGN_UP" + if self.is_signup + else "AUTHENTICATION_FAILED_SIGN_IN" + ) + ], + payload={"email": self.key}, + ) + + super().set_user_data( + { + "email": self.key, + "user": { + "avatar": "", + "first_name": "", + "last_name": "", + "provider_id": "", + "is_password_autoset": False, + }, + } + ) + return diff --git a/apiserver/plane/authentication/provider/credentials/magic_code.py b/apiserver/plane/authentication/provider/credentials/magic_code.py new file mode 100644 index 000000000..71451ef0d --- /dev/null +++ b/apiserver/plane/authentication/provider/credentials/magic_code.py @@ -0,0 +1,130 @@ +# Python imports +import json +import os +import random +import string + + +# Module imports +from plane.authentication.adapter.credential import CredentialAdapter +from plane.license.utils.instance_value import get_configuration_value +from plane.settings.redis import redis_instance +from plane.authentication.adapter.error import ( + AUTHENTICATION_ERROR_CODES, + AuthenticationException, +) + + +class MagicCodeProvider(CredentialAdapter): + + provider = "magic-code" + + def __init__( + self, + request, + key, + code=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"), + }, + ] + ) + ) + + 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) + self.key = key + self.code = code + + def initiate(self): + ## Generate a random token + token = ( + "".join(random.choices(string.ascii_lowercase, k=4)) + + "-" + + "".join(random.choices(string.ascii_lowercase, k=4)) + + "-" + + "".join(random.choices(string.ascii_lowercase, k=4)) + ) + + ri = redis_instance() + + key = "magic_" + str(self.key) + + # Check if the key already exists in python + if ri.exists(key): + data = json.loads(ri.get(key)) + + current_attempt = data["current_attempt"] + 1 + + if data["current_attempt"] > 2: + return key, "" + + value = { + "current_attempt": current_attempt, + "email": str(self.key), + "token": token, + } + expiry = 600 + ri.set(key, json.dumps(value), ex=expiry) + else: + value = {"current_attempt": 0, "email": self.key, "token": token} + expiry = 600 + + ri.set(key, json.dumps(value), ex=expiry) + return key, token + + def set_user_data(self): + ri = redis_instance() + if ri.exists(self.key): + data = json.loads(ri.get(self.key)) + token = data["token"] + email = data["email"] + + if str(token) == str(self.code): + super().set_user_data( + { + "email": email, + "user": { + "avatar": "", + "first_name": "", + "last_name": "", + "provider_id": "", + "is_password_autoset": True, + }, + } + ) + return + else: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INVALID_MAGIC_CODE" + ], + error_message="INVALID_MAGIC_CODE", + payload={"email": str(email)}, + ) + else: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["EXPIRED_MAGIC_CODE"], + error_message="EXPIRED_MAGIC_CODE", + payload={"email": str(self.key)}, + ) diff --git a/apiserver/plane/authentication/provider/oauth/__init__.py b/apiserver/plane/authentication/provider/oauth/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/authentication/provider/oauth/github.py b/apiserver/plane/authentication/provider/oauth/github.py new file mode 100644 index 000000000..29e1772b5 --- /dev/null +++ b/apiserver/plane/authentication/provider/oauth/github.py @@ -0,0 +1,136 @@ +# Python imports +import os +from datetime import datetime +from urllib.parse import urlencode + +import pytz +import requests + +# Module imports +from plane.authentication.adapter.oauth import OauthAdapter +from plane.license.utils.instance_value import get_configuration_value +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) + + +class GitHubOAuthProvider(OauthAdapter): + + token_url = "https://github.com/login/oauth/access_token" + userinfo_url = "https://api.github.com/user" + provider = "github" + scope = "read:user user:email" + + def __init__(self, request, code=None, state=None): + + GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET = get_configuration_value( + [ + { + "key": "GITHUB_CLIENT_ID", + "default": os.environ.get("GITHUB_CLIENT_ID"), + }, + { + "key": "GITHUB_CLIENT_SECRET", + "default": os.environ.get("GITHUB_CLIENT_SECRET"), + }, + ] + ) + + if not (GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET): + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITHUB_NOT_CONFIGURED"], + error_message="GITHUB_NOT_CONFIGURED", + ) + + client_id = GITHUB_CLIENT_ID + client_secret = GITHUB_CLIENT_SECRET + + redirect_uri = ( + f"{request.scheme}://{request.get_host()}/auth/github/callback/" + ) + url_params = { + "client_id": client_id, + "redirect_uri": redirect_uri, + "scope": self.scope, + "state": state, + } + auth_url = ( + f"https://github.com/login/oauth/authorize?{urlencode(url_params)}" + ) + super().__init__( + request, + self.provider, + client_id, + self.scope, + redirect_uri, + auth_url, + self.token_url, + self.userinfo_url, + client_secret, + code, + ) + + def set_token_data(self): + data = { + "client_id": self.client_id, + "client_secret": self.client_secret, + "code": self.code, + "redirect_uri": self.redirect_uri, + } + token_response = self.get_user_token( + data=data, headers={"Accept": "application/json"} + ) + super().set_token_data( + { + "access_token": token_response.get("access_token"), + "refresh_token": token_response.get("refresh_token", None), + "access_token_expired_at": ( + datetime.fromtimestamp( + token_response.get("expires_in"), + tz=pytz.utc, + ) + if token_response.get("expires_in") + else None + ), + "refresh_token_expired_at": ( + datetime.fromtimestamp( + token_response.get("refresh_token_expired_at"), + tz=pytz.utc, + ) + if token_response.get("refresh_token_expired_at") + else None + ), + } + ) + + def __get_email(self, headers): + # Github does not provide email in user response + emails_url = "https://api.github.com/user/emails" + emails_response = requests.get(emails_url, headers=headers).json() + email = next( + (email["email"] for email in emails_response if email["primary"]), + None, + ) + return email + + def set_user_data(self): + user_info_response = self.get_user_response() + headers = { + "Authorization": f"Bearer {self.token_data.get('access_token')}", + "Accept": "application/json", + } + email = self.__get_email(headers=headers) + super().set_user_data( + { + "email": email, + "user": { + "provider_id": user_info_response.get("id"), + "email": email, + "avatar": user_info_response.get("avatar_url"), + "first_name": user_info_response.get("name"), + "last_name": user_info_response.get("family_name"), + "is_password_autoset": True, + }, + } + ) diff --git a/apiserver/plane/authentication/provider/oauth/google.py b/apiserver/plane/authentication/provider/oauth/google.py new file mode 100644 index 000000000..d0893d23b --- /dev/null +++ b/apiserver/plane/authentication/provider/oauth/google.py @@ -0,0 +1,117 @@ +# Python imports +import os +from datetime import datetime +from urllib.parse import urlencode + +import pytz + +# Module imports +from plane.authentication.adapter.oauth import OauthAdapter +from plane.license.utils.instance_value import get_configuration_value +from plane.authentication.adapter.error import ( + AUTHENTICATION_ERROR_CODES, + AuthenticationException, +) + + +class GoogleOAuthProvider(OauthAdapter): + token_url = "https://oauth2.googleapis.com/token" + userinfo_url = "https://www.googleapis.com/oauth2/v2/userinfo" + scope = "https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile" + provider = "google" + + def __init__(self, request, code=None, state=None): + (GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET) = get_configuration_value( + [ + { + "key": "GOOGLE_CLIENT_ID", + "default": os.environ.get("GOOGLE_CLIENT_ID"), + }, + { + "key": "GOOGLE_CLIENT_SECRET", + "default": os.environ.get("GOOGLE_CLIENT_SECRET"), + }, + ] + ) + + if not (GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET): + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GOOGLE_NOT_CONFIGURED"], + error_message="GOOGLE_NOT_CONFIGURED", + ) + + client_id = GOOGLE_CLIENT_ID + client_secret = GOOGLE_CLIENT_SECRET + + redirect_uri = ( + f"{request.scheme}://{request.get_host()}/auth/google/callback/" + ) + url_params = { + "client_id": client_id, + "scope": self.scope, + "redirect_uri": redirect_uri, + "response_type": "code", + "access_type": "offline", + "prompt": "consent", + "state": state, + } + auth_url = f"https://accounts.google.com/o/oauth2/v2/auth?{urlencode(url_params)}" + + super().__init__( + request, + self.provider, + client_id, + self.scope, + redirect_uri, + auth_url, + self.token_url, + self.userinfo_url, + client_secret, + code, + ) + + def set_token_data(self): + data = { + "code": self.code, + "client_id": self.client_id, + "client_secret": self.client_secret, + "redirect_uri": self.redirect_uri, + "grant_type": "authorization_code", + } + token_response = self.get_user_token(data=data) + super().set_token_data( + { + "access_token": token_response.get("access_token"), + "refresh_token": token_response.get("refresh_token", None), + "access_token_expired_at": ( + datetime.fromtimestamp( + token_response.get("expires_in"), + tz=pytz.utc, + ) + if token_response.get("expires_in") + else None + ), + "refresh_token_expired_at": ( + datetime.fromtimestamp( + token_response.get("refresh_token_expired_at"), + tz=pytz.utc, + ) + if token_response.get("refresh_token_expired_at") + else None + ), + } + ) + + def set_user_data(self): + user_info_response = self.get_user_response() + user_data = { + "email": user_info_response.get("email"), + "user": { + "avatar": user_info_response.get("picture"), + "first_name": user_info_response.get("given_name"), + "last_name": user_info_response.get("family_name"), + "provider_id": user_info_response.get("id"), + "is_password_autoset": True, + }, + } + super().set_user_data(user_data) diff --git a/apiserver/plane/authentication/session.py b/apiserver/plane/authentication/session.py new file mode 100644 index 000000000..7bb0b4a00 --- /dev/null +++ b/apiserver/plane/authentication/session.py @@ -0,0 +1,8 @@ +from rest_framework.authentication import SessionAuthentication + + +class BaseSessionAuthentication(SessionAuthentication): + + # Disable csrf for the rest apis + def enforce_csrf(self, request): + return diff --git a/apiserver/plane/authentication/urls.py b/apiserver/plane/authentication/urls.py new file mode 100644 index 000000000..451b17e4e --- /dev/null +++ b/apiserver/plane/authentication/urls.py @@ -0,0 +1,184 @@ +from django.urls import path + +from .views import ( + CSRFTokenEndpoint, + EmailCheckSignInEndpoint, + EmailCheckSignUpEndpoint, + ForgotPasswordEndpoint, + SetUserPasswordEndpoint, + ResetPasswordEndpoint, + # App + GitHubCallbackEndpoint, + GitHubOauthInitiateEndpoint, + GoogleCallbackEndpoint, + GoogleOauthInitiateEndpoint, + MagicGenerateEndpoint, + MagicSignInEndpoint, + MagicSignUpEndpoint, + SignInAuthEndpoint, + SignOutAuthEndpoint, + SignUpAuthEndpoint, + # Space + EmailCheckEndpoint, + GitHubCallbackSpaceEndpoint, + GitHubOauthInitiateSpaceEndpoint, + GoogleCallbackSpaceEndpoint, + GoogleOauthInitiateSpaceEndpoint, + MagicGenerateSpaceEndpoint, + MagicSignInSpaceEndpoint, + MagicSignUpSpaceEndpoint, + SignInAuthSpaceEndpoint, + SignUpAuthSpaceEndpoint, + SignOutAuthSpaceEndpoint, +) + +urlpatterns = [ + # credentials + path( + "sign-in/", + SignInAuthEndpoint.as_view(), + name="sign-in", + ), + path( + "sign-up/", + SignUpAuthEndpoint.as_view(), + name="sign-up", + ), + path( + "spaces/sign-in/", + SignInAuthSpaceEndpoint.as_view(), + name="sign-in", + ), + path( + "spaces/sign-up/", + SignUpAuthSpaceEndpoint.as_view(), + name="sign-in", + ), + # signout + path( + "sign-out/", + SignOutAuthEndpoint.as_view(), + name="sign-out", + ), + path( + "spaces/sign-out/", + SignOutAuthSpaceEndpoint.as_view(), + name="sign-out", + ), + # csrf token + path( + "get-csrf-token/", + CSRFTokenEndpoint.as_view(), + name="get_csrf_token", + ), + # Magic sign in + path( + "magic-generate/", + MagicGenerateEndpoint.as_view(), + name="magic-generate", + ), + path( + "magic-sign-in/", + MagicSignInEndpoint.as_view(), + name="magic-sign-in", + ), + path( + "magic-sign-up/", + MagicSignUpEndpoint.as_view(), + name="magic-sign-up", + ), + path( + "get-csrf-token/", + CSRFTokenEndpoint.as_view(), + name="get_csrf_token", + ), + path( + "spaces/magic-generate/", + MagicGenerateSpaceEndpoint.as_view(), + name="magic-generate", + ), + path( + "spaces/magic-sign-in/", + MagicSignInSpaceEndpoint.as_view(), + name="magic-sign-in", + ), + path( + "spaces/magic-sign-up/", + MagicSignUpSpaceEndpoint.as_view(), + name="magic-sign-up", + ), + ## Google Oauth + path( + "google/", + GoogleOauthInitiateEndpoint.as_view(), + name="google-initiate", + ), + path( + "google/callback/", + GoogleCallbackEndpoint.as_view(), + name="google-callback", + ), + path( + "spaces/google/", + GoogleOauthInitiateSpaceEndpoint.as_view(), + name="google-initiate", + ), + path( + "google/callback/", + GoogleCallbackSpaceEndpoint.as_view(), + name="google-callback", + ), + ## Github Oauth + path( + "github/", + GitHubOauthInitiateEndpoint.as_view(), + name="github-initiate", + ), + path( + "github/callback/", + GitHubCallbackEndpoint.as_view(), + name="github-callback", + ), + path( + "spaces/github/", + GitHubOauthInitiateSpaceEndpoint.as_view(), + name="github-initiate", + ), + path( + "spaces/github/callback/", + GitHubCallbackSpaceEndpoint.as_view(), + name="github-callback", + ), + # Email Check + path( + "sign-up/email-check/", + EmailCheckSignUpEndpoint.as_view(), + name="email-check-sign-up", + ), + path( + "sign-in/email-check/", + EmailCheckSignInEndpoint.as_view(), + name="email-check-sign-in", + ), + path( + "spaces/email-check/", + EmailCheckEndpoint.as_view(), + name="email-check", + ), + # Password + path( + "forgot-password/", + ForgotPasswordEndpoint.as_view(), + name="forgot-password", + ), + path( + "reset-password///", + ResetPasswordEndpoint.as_view(), + name="forgot-password", + ), + path( + "set-password/", + SetUserPasswordEndpoint.as_view(), + name="set-password", + ), +] diff --git a/apiserver/plane/authentication/utils/host.py b/apiserver/plane/authentication/utils/host.py new file mode 100644 index 000000000..d5a81a249 --- /dev/null +++ b/apiserver/plane/authentication/utils/host.py @@ -0,0 +1,14 @@ +from urllib.parse import urlsplit + + +def base_host(request): + """Utility function to return host / origin from the request""" + return ( + 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()}" + ) + + +def user_ip(request): + return str(request.META.get("REMOTE_ADDR")) diff --git a/apiserver/plane/authentication/utils/login.py b/apiserver/plane/authentication/utils/login.py new file mode 100644 index 000000000..88a988c8f --- /dev/null +++ b/apiserver/plane/authentication/utils/login.py @@ -0,0 +1,17 @@ +# Django imports +from django.contrib.auth import login + +# Module imports +from plane.authentication.utils.host import base_host + + +def user_login(request, user): + login(request=request, user=user) + device_info = { + "user_agent": request.META.get("HTTP_USER_AGENT", ""), + "ip_address": request.META.get("REMOTE_ADDR", ""), + "domain": base_host(request=request), + } + request.session["device_info"] = device_info + request.session.save() + return diff --git a/apiserver/plane/authentication/utils/redirection_path.py b/apiserver/plane/authentication/utils/redirection_path.py new file mode 100644 index 000000000..bf9e15673 --- /dev/null +++ b/apiserver/plane/authentication/utils/redirection_path.py @@ -0,0 +1,42 @@ +from plane.db.models import Profile, Workspace, WorkspaceMemberInvite + + +def get_redirection_path(user): + # Handle redirections + profile = Profile.objects.get(user=user) + + # Redirect to onboarding if the user is not onboarded yet + if not profile.is_onboarded: + return "onboarding" + + # Redirect to the last workspace if the user has last workspace + if profile.last_workspace_id and Workspace.objects.filter( + pk=profile.last_workspace_id, + workspace_member__member_id=user.id, + workspace_member__is_active=True, + ): + workspace = Workspace.objects.filter( + pk=profile.last_workspace_id, + workspace_member__member_id=user.id, + workspace_member__is_active=True, + ).first() + return f"{workspace.slug}" + + fallback_workspace = ( + Workspace.objects.filter( + workspace_member__member_id=user.id, + workspace_member__is_active=True, + ) + .order_by("created_at") + .first() + ) + # Redirect to fallback workspace + if fallback_workspace: + return f"{fallback_workspace.slug}" + + # Redirect to invitations if the user has unaccepted invitations + if WorkspaceMemberInvite.objects.filter(email=user.email).count(): + return "invitations" + + # Redirect the user to create workspace + return "create-workspace" diff --git a/apiserver/plane/authentication/utils/workspace_project_join.py b/apiserver/plane/authentication/utils/workspace_project_join.py new file mode 100644 index 000000000..8910ec637 --- /dev/null +++ b/apiserver/plane/authentication/utils/workspace_project_join.py @@ -0,0 +1,72 @@ +from plane.db.models import ( + ProjectMember, + ProjectMemberInvite, + WorkspaceMember, + WorkspaceMemberInvite, +) + + +def process_workspace_project_invitations(user): + """This function takes in User and adds him to all workspace and projects that the user has accepted invited of""" + + # Check if user has any accepted invites for workspace and add them to workspace + workspace_member_invites = WorkspaceMemberInvite.objects.filter( + email=user.email, accepted=True + ) + + WorkspaceMember.objects.bulk_create( + [ + WorkspaceMember( + workspace_id=workspace_member_invite.workspace_id, + member=user, + role=workspace_member_invite.role, + ) + for workspace_member_invite in workspace_member_invites + ], + ignore_conflicts=True, + ) + + # Check if user has any project invites + project_member_invites = ProjectMemberInvite.objects.filter( + email=user.email, accepted=True + ) + + # Add user to workspace + WorkspaceMember.objects.bulk_create( + [ + WorkspaceMember( + workspace_id=project_member_invite.workspace_id, + role=( + project_member_invite.role + if project_member_invite.role in [5, 10, 15] + else 15 + ), + member=user, + created_by_id=project_member_invite.created_by_id, + ) + for project_member_invite in project_member_invites + ], + ignore_conflicts=True, + ) + + # Now add the users to project + ProjectMember.objects.bulk_create( + [ + ProjectMember( + workspace_id=project_member_invite.workspace_id, + role=( + project_member_invite.role + if project_member_invite.role in [5, 10, 15] + else 15 + ), + member=user, + created_by_id=project_member_invite.created_by_id, + ) + for project_member_invite in project_member_invites + ], + ignore_conflicts=True, + ) + + # Delete all the invites + workspace_member_invites.delete() + project_member_invites.delete() diff --git a/apiserver/plane/authentication/views/__init__.py b/apiserver/plane/authentication/views/__init__.py new file mode 100644 index 000000000..4bd920e29 --- /dev/null +++ b/apiserver/plane/authentication/views/__init__.py @@ -0,0 +1,52 @@ +from .common import ( + ChangePasswordEndpoint, + CSRFTokenEndpoint, + ForgotPasswordEndpoint, + ResetPasswordEndpoint, + SetUserPasswordEndpoint, +) + +from .app.check import EmailCheckSignInEndpoint, EmailCheckSignUpEndpoint + +from .app.email import ( + SignInAuthEndpoint, + SignUpAuthEndpoint, +) +from .app.github import ( + GitHubCallbackEndpoint, + GitHubOauthInitiateEndpoint, +) +from .app.google import ( + GoogleCallbackEndpoint, + GoogleOauthInitiateEndpoint, +) +from .app.magic import ( + MagicGenerateEndpoint, + MagicSignInEndpoint, + MagicSignUpEndpoint, +) + +from .app.signout import SignOutAuthEndpoint + + +from .space.email import SignInAuthSpaceEndpoint, SignUpAuthSpaceEndpoint + +from .space.github import ( + GitHubCallbackSpaceEndpoint, + GitHubOauthInitiateSpaceEndpoint, +) + +from .space.google import ( + GoogleCallbackSpaceEndpoint, + GoogleOauthInitiateSpaceEndpoint, +) + +from .space.magic import ( + MagicGenerateSpaceEndpoint, + MagicSignInSpaceEndpoint, + MagicSignUpSpaceEndpoint, +) + +from .space.signout import SignOutAuthSpaceEndpoint + +from .space.check import EmailCheckEndpoint diff --git a/apiserver/plane/authentication/views/app/check.py b/apiserver/plane/authentication/views/app/check.py new file mode 100644 index 000000000..0abefd79f --- /dev/null +++ b/apiserver/plane/authentication/views/app/check.py @@ -0,0 +1,148 @@ +# Django imports +from django.core.validators import validate_email +from django.core.exceptions import ValidationError + +# Third party imports +from rest_framework import status +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView + +## Module imports +from plane.db.models import User +from plane.license.models import Instance +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) + + +class EmailCheckSignUpEndpoint(APIView): + + permission_classes = [ + AllowAny, + ] + + def post(self, request): + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INSTANCE_NOT_CONFIGURED" + ], + error_message="INSTANCE_NOT_CONFIGURED", + ) + return Response( + exc.get_error_dict(), + status=status.HTTP_400_BAD_REQUEST, + ) + + email = 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, + ) + + 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", + ) + return Response( + exc.get_error_dict(), + status=status.HTTP_400_BAD_REQUEST, + ) + return Response( + {"status": True}, + status=status.HTTP_200_OK, + ) + + +class EmailCheckSignInEndpoint(APIView): + + permission_classes = [ + AllowAny, + ] + + def post(self, request): + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INSTANCE_NOT_CONFIGURED" + ], + error_message="INSTANCE_NOT_CONFIGURED", + ) + return Response( + exc.get_error_dict(), + status=status.HTTP_400_BAD_REQUEST, + ) + + email = 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, + ) + + existing_user = User.objects.filter(email=email).first() + + if existing_user: + return Response( + { + "status": True, + "is_password_autoset": existing_user.is_password_autoset, + }, + 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, + ) diff --git a/apiserver/plane/authentication/views/app/email.py b/apiserver/plane/authentication/views/app/email.py new file mode 100644 index 000000000..7ef6ac9f4 --- /dev/null +++ b/apiserver/plane/authentication/views/app/email.py @@ -0,0 +1,240 @@ +# Python imports +from urllib.parse import urlencode, urljoin + +# Django imports +from django.core.exceptions import ValidationError +from django.core.validators import validate_email +from django.http import HttpResponseRedirect +from django.views import View + +# Module imports +from plane.authentication.provider.credentials.email import EmailProvider +from plane.authentication.utils.login import user_login +from plane.license.models import Instance +from plane.authentication.utils.host import base_host +from plane.authentication.utils.redirection_path import get_redirection_path +from plane.authentication.utils.workspace_project_join import ( + process_workspace_project_invitations, +) +from plane.db.models import User +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) + + +class SignInAuthEndpoint(View): + + def post(self, request): + next_path = request.POST.get("next_path") + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + # Redirection params + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INSTANCE_NOT_CONFIGURED" + ], + error_message="INSTANCE_NOT_CONFIGURED", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + # Base URL join + url = urljoin( + base_host(request=request), + "accounts/sign-in?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + # set the referer as session to redirect after login + email = request.POST.get("email", False) + password = request.POST.get("password", False) + + ## Raise exception if any of the above are missing + if not email or not password: + # Redirection params + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "REQUIRED_EMAIL_PASSWORD_SIGN_IN" + ], + error_message="REQUIRED_EMAIL_PASSWORD_SIGN_IN", + payload={"email": str(email)}, + ) + params = exc.get_error_dict() + # Next path + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request), + "accounts/sign-in?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + # Validate email + email = email.strip().lower() + try: + validate_email(email) + except ValidationError: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL_SIGN_IN"], + error_message="INVALID_EMAIL_SIGN_IN", + payload={"email": str(email)}, + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request), + "accounts/sign-in?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + if not User.objects.filter(email=email).exists(): + 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), + ) + return HttpResponseRedirect(url) + + try: + provider = EmailProvider( + request=request, key=email, code=password, is_signup=False + ) + user = provider.authenticate() + # Login the user and record his device info + user_login(request=request, user=user) + # Process workspace and project invitations + process_workspace_project_invitations(user=user) + # 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) + return HttpResponseRedirect(url) + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request), + "accounts/sign-in?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + +class SignUpAuthEndpoint(View): + + def post(self, request): + next_path = request.POST.get("next_path") + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + # Redirection params + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INSTANCE_NOT_CONFIGURED" + ], + error_message="INSTANCE_NOT_CONFIGURED", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request), + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + email = request.POST.get("email", False) + password = request.POST.get("password", False) + ## Raise exception if any of the above are missing + if not email or not password: + # Redirection params + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "REQUIRED_EMAIL_PASSWORD_SIGN_UP" + ], + error_message="REQUIRED_EMAIL_PASSWORD_SIGN_UP", + payload={"email": str(email)}, + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request), + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + # Validate the email + email = email.strip().lower() + try: + validate_email(email) + except ValidationError: + # Redirection params + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL_SIGN_UP"], + error_message="INVALID_EMAIL_SIGN_UP", + payload={"email": str(email)}, + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request), + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + if User.objects.filter(email=email).exists(): + 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), + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + try: + provider = EmailProvider( + request=request, key=email, code=password, is_signup=True + ) + user = provider.authenticate() + # Login the user and record his device info + user_login(request=request, user=user) + # Process workspace and project invitations + process_workspace_project_invitations(user=user) + # 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) + return HttpResponseRedirect(url) + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request), + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/app/github.py b/apiserver/plane/authentication/views/app/github.py new file mode 100644 index 000000000..e7184b16e --- /dev/null +++ b/apiserver/plane/authentication/views/app/github.py @@ -0,0 +1,133 @@ +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.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.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) + next_path = request.GET.get("next_path") + if next_path: + request.session["next_path"] = str(next_path) + + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INSTANCE_NOT_CONFIGURED" + ], + error_message="INSTANCE_NOT_CONFIGURED", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request), + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + try: + state = uuid.uuid4().hex + provider = GitHubOAuthProvider(request=request, state=state) + request.session["state"] = state + auth_url = provider.get_auth_url() + return HttpResponseRedirect(auth_url) + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request), + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + +class GitHubCallbackEndpoint(View): + + def get(self, request): + code = request.GET.get("code") + state = request.GET.get("state") + base_host = request.session.get("host") + next_path = request.session.get("next_path") + + if state != request.session.get("state", ""): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "GITHUB_OAUTH_PROVIDER_ERROR" + ], + error_message="GITHUB_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host, + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + if not code: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "GITHUB_OAUTH_PROVIDER_ERROR" + ], + error_message="GITHUB_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host, + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + try: + provider = GitHubOAuthProvider( + request=request, + code=code, + ) + 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) + # Get the redirection path + if next_path: + path = next_path + else: + path = get_redirection_path(user=user) + # redirect to referer path + url = urljoin(base_host, path) + return HttpResponseRedirect(url) + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host, + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/app/google.py b/apiserver/plane/authentication/views/app/google.py new file mode 100644 index 000000000..19c59691c --- /dev/null +++ b/apiserver/plane/authentication/views/app/google.py @@ -0,0 +1,128 @@ +# Python imports +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 + +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, +) + +# 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) + next_path = request.GET.get("next_path") + if next_path: + request.session["next_path"] = str(next_path) + + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INSTANCE_NOT_CONFIGURED" + ], + error_message="INSTANCE_NOT_CONFIGURED", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request), + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + try: + state = uuid.uuid4().hex + provider = GoogleOAuthProvider(request=request, state=state) + request.session["state"] = state + auth_url = provider.get_auth_url() + return HttpResponseRedirect(auth_url) + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request), + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + +class GoogleCallbackEndpoint(View): + def get(self, request): + code = request.GET.get("code") + state = request.GET.get("state") + base_host = request.session.get("host") + next_path = request.session.get("next_path") + + if state != request.session.get("state", ""): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "GOOGLE_OAUTH_PROVIDER_ERROR" + ], + error_message="GOOGLE_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host, + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + if not code: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "GOOGLE_OAUTH_PROVIDER_ERROR" + ], + error_message="GOOGLE_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = next_path + url = urljoin( + base_host, + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + try: + provider = GoogleOAuthProvider( + request=request, + code=code, + ) + 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) + # Get the redirection path + path = get_redirection_path(user=user) + # redirect to referer path + url = urljoin(base_host, str(next_path) if next_path else path) + return HttpResponseRedirect(url) + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host, + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/app/magic.py b/apiserver/plane/authentication/views/app/magic.py new file mode 100644 index 000000000..8f9f3633b --- /dev/null +++ b/apiserver/plane/authentication/views/app/magic.py @@ -0,0 +1,211 @@ +# Python imports +from urllib.parse import urlencode, urljoin + +# Django imports +from django.core.validators import validate_email +from django.http import HttpResponseRedirect +from django.views import View + +# Third party imports +from rest_framework import status +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView + +# Module imports +from plane.authentication.provider.credentials.magic_code import ( + MagicCodeProvider, +) +from plane.authentication.utils.login import user_login +from plane.authentication.utils.redirection_path import get_redirection_path +from plane.authentication.utils.workspace_project_join import ( + process_workspace_project_invitations, +) +from plane.bgtasks.magic_link_code_task import magic_link +from plane.license.models import Instance +from plane.authentication.utils.host import base_host +from plane.db.models import User, Profile +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) + + +class MagicGenerateEndpoint(APIView): + + permission_classes = [ + AllowAny, + ] + + def post(self, request): + # Check if instance is configured + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INSTANCE_NOT_CONFIGURED" + ], + error_message="INSTANCE_NOT_CONFIGURED", + ) + return Response( + exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST + ) + + origin = request.META.get("HTTP_ORIGIN", "/") + email = request.data.get("email", False) + try: + # Clean up the email + email = email.strip().lower() + validate_email(email) + adapter = MagicCodeProvider(request=request, key=email) + key, token = adapter.initiate() + # If the smtp is configured send through here + magic_link.delay(email, key, token, origin) + return Response({"key": str(key)}, status=status.HTTP_200_OK) + except AuthenticationException as e: + params = e.get_error_dict() + return Response( + params, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class MagicSignInEndpoint(View): + + def post(self, request): + + # set the referer as session to redirect after login + code = request.POST.get("code", "").strip() + email = request.POST.get("email", "").strip().lower() + next_path = request.POST.get("next_path") + + if code == "" or email == "": + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED" + ], + error_message="MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request), + "accounts/sign-in?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + if not User.objects.filter(email=email).exists(): + 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), + ) + return HttpResponseRedirect(url) + + try: + provider = MagicCodeProvider( + request=request, key=f"magic_{email}", code=code + ) + 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 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)) + ) + # redirect to referer path + url = urljoin(base_host(request=request), path) + return HttpResponseRedirect(url) + + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request), + "accounts/sign-in?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + +class MagicSignUpEndpoint(View): + + def post(self, request): + + # set the referer as session to redirect after login + code = request.POST.get("code", "").strip() + email = request.POST.get("email", "").strip().lower() + next_path = request.POST.get("next_path") + + if code == "" or email == "": + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED" + ], + error_message="MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request), + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + if User.objects.filter(email=email).exists(): + 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), + ) + 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) + # Process workspace and project invitations + process_workspace_project_invitations(user=user) + # 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) + return HttpResponseRedirect(url) + + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request), + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/app/signout.py b/apiserver/plane/authentication/views/app/signout.py new file mode 100644 index 000000000..967f398eb --- /dev/null +++ b/apiserver/plane/authentication/views/app/signout.py @@ -0,0 +1,34 @@ +# 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 user_ip, base_host +from plane.db.models import User + + +class SignOutAuthEndpoint(View): + + def post(self, request): + # Get user + try: + user = User.objects.get(pk=request.user.id) + user.last_logout_ip = user_ip(request=request) + user.last_logout_time = timezone.now() + user.save() + # Log the user out + logout(request) + url = urljoin( + base_host(request=request), + "accounts/sign-in?" + urlencode({"success": "true"}), + ) + return HttpResponseRedirect(url) + except Exception: + return HttpResponseRedirect( + base_host(request=request), "accounts/sign-in" + ) diff --git a/apiserver/plane/authentication/views/common.py b/apiserver/plane/authentication/views/common.py new file mode 100644 index 000000000..22fbb0a5c --- /dev/null +++ b/apiserver/plane/authentication/views/common.py @@ -0,0 +1,326 @@ +# 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 +from rest_framework.response import Response +from rest_framework.views import APIView +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, +) + + +class CSRFTokenEndpoint(APIView): + + permission_classes = [ + AllowAny, + ] + + def get(self, request): + # Generate a CSRF token + csrf_token = get_token(request) + # Return the CSRF token in a JSON response + return Response( + {"csrf_token": str(csrf_token)}, status=status.HTTP_200_OK + ) + + +def 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, 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 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), + "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), + "?" + 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), + "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), + "accounts/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), + "accounts/reset-password?" + urlencode(exc.get_error_dict()), + ) + 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")): + 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(serializer.data.get("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(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, + ) + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"], + error_message="INVALID_PASSWORD", + ) + return Response( + exc.get_error_dict(), + status=status.HTTP_400_BAD_REQUEST, + ) + + +class SetUserPasswordEndpoint(APIView): + def post(self, request): + user = User.objects.get(pk=request.user.id) + password = request.data.get("password", False) + + # If the user password is not autoset then return error + if not user.is_password_autoset: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_ALREADY_SET"], + error_message="PASSWORD_ALREADY_SET", + payload={ + "error": "Your password is already set please change your password from profile" + }, + ) + return Response( + exc.get_error_dict(), + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check password validation + if not password: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"], + error_message="INVALID_PASSWORD", + ) + return Response( + exc.get_error_dict(), + status=status.HTTP_400_BAD_REQUEST, + ) + + results = zxcvbn(password) + if results["score"] < 3: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"], + error_message="INVALID_PASSWORD", + ) + return Response( + exc.get_error_dict(), + status=status.HTTP_400_BAD_REQUEST, + ) + + # Set the user password + user.set_password(password) + user.is_password_autoset = False + user.save() + # Login the user as the session is invalidated + user_login(user=user, request=request) + # Return the user + serializer = UserSerializer(user) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/authentication/views/space/check.py b/apiserver/plane/authentication/views/space/check.py new file mode 100644 index 000000000..49baad081 --- /dev/null +++ b/apiserver/plane/authentication/views/space/check.py @@ -0,0 +1,82 @@ +# Django imports +from django.core.validators import validate_email +from django.core.exceptions import ValidationError + +# Third party imports +from rest_framework import status +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView + +## Module imports +from plane.db.models import User +from plane.license.models import Instance +from plane.authentication.adapter.error import ( + AUTHENTICATION_ERROR_CODES, + AuthenticationException, +) + + +class EmailCheckEndpoint(APIView): + + permission_classes = [ + AllowAny, + ] + + def post(self, request): + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INSTANCE_NOT_CONFIGURED" + ], + error_message="INSTANCE_NOT_CONFIGURED", + ) + return Response( + exc.get_error_dict(), + status=status.HTTP_400_BAD_REQUEST, + ) + + email = 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: + return Response( + { + "existing": True, + "is_password_autoset": existing_user.is_password_autoset, + }, + status=status.HTTP_200_OK, + ) + # Else return response + return Response( + {"existing": False, "is_password_autoset": False}, + status=status.HTTP_200_OK, + ) diff --git a/apiserver/plane/authentication/views/space/email.py b/apiserver/plane/authentication/views/space/email.py new file mode 100644 index 000000000..b0a82fe6f --- /dev/null +++ b/apiserver/plane/authentication/views/space/email.py @@ -0,0 +1,224 @@ +# Python imports +from urllib.parse import urlencode, urljoin + +# Django imports +from django.core.exceptions import ValidationError +from django.core.validators import validate_email +from django.http import HttpResponseRedirect +from django.views import View + +# Module imports +from plane.authentication.provider.credentials.email import EmailProvider +from plane.authentication.utils.login import user_login +from plane.license.models import Instance +from plane.authentication.utils.host import base_host +from plane.db.models import User +from plane.authentication.adapter.error import ( + AUTHENTICATION_ERROR_CODES, + AuthenticationException, +) + + +class SignInAuthSpaceEndpoint(View): + + def post(self, request): + next_path = request.POST.get("next_path") + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + # Redirection params + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INSTANCE_NOT_CONFIGURED" + ], + error_message="INSTANCE_NOT_CONFIGURED", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request), + "accounts/sign-in?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + # set the referer as session to redirect after login + email = request.POST.get("email", False) + password = request.POST.get("password", False) + + ## Raise exception if any of the above are missing + if not email or not password: + 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), + ) + return HttpResponseRedirect(url) + + # Validate email + email = email.strip().lower() + try: + validate_email(email) + except ValidationError: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL_SIGN_IN"], + error_message="INVALID_EMAIL_SIGN_IN", + payload={"email": str(email)}, + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request), + "spaces/accounts/sign-in?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + if not User.objects.filter(email=email).exists(): + 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), + ) + return HttpResponseRedirect(url) + + try: + provider = EmailProvider( + request=request, key=email, code=password, is_signup=False + ) + user = provider.authenticate() + # Login the user and record his device info + user_login(request=request, user=user) + # redirect to next path + url = urljoin( + base_host(request=request), + str(next_path) if next_path else "/", + ) + return HttpResponseRedirect(url) + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request), + "spaces/accounts/sign-in?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + +class SignUpAuthSpaceEndpoint(View): + + def post(self, request): + next_path = request.POST.get("next_path") + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + # Redirection params + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INSTANCE_NOT_CONFIGURED" + ], + error_message="INSTANCE_NOT_CONFIGURED", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request), + "spaces?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + email = request.POST.get("email", False) + password = request.POST.get("password", False) + ## Raise exception if any of the above are missing + if not email or not password: + # Redirection params + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "REQUIRED_EMAIL_PASSWORD_SIGN_UP" + ], + error_message="REQUIRED_EMAIL_PASSWORD_SIGN_UP", + payload={"email": str(email)}, + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request), + "spaces?" + urlencode(params), + ) + return HttpResponseRedirect(url) + # Validate the email + email = email.strip().lower() + try: + validate_email(email) + except ValidationError: + # Redirection params + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL_SIGN_UP"], + error_message="INVALID_EMAIL_SIGN_UP", + payload={"email": str(email)}, + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request), + "spaces?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + if User.objects.filter(email=email).exists(): + 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), + ) + return HttpResponseRedirect(url) + + try: + provider = EmailProvider( + request=request, key=email, code=password, is_signup=True + ) + user = provider.authenticate() + # Login the user and record his device info + user_login(request=request, user=user) + # redirect to referer path + url = urljoin( + base_host(request=request), + str(next_path) if next_path else "spaces", + ) + return HttpResponseRedirect(url) + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request), + "spaces?" + urlencode(params), + ) + return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/space/github.py b/apiserver/plane/authentication/views/space/github.py new file mode 100644 index 000000000..192f0d039 --- /dev/null +++ b/apiserver/plane/authentication/views/space/github.py @@ -0,0 +1,125 @@ +# Python imports +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.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) + next_path = request.GET.get("next_path") + if next_path: + request.session["next_path"] = str(next_path) + + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INSTANCE_NOT_CONFIGURED" + ], + error_message="INSTANCE_NOT_CONFIGURED", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request), + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + try: + state = uuid.uuid4().hex + provider = GitHubOAuthProvider(request=request, state=state) + request.session["state"] = state + auth_url = provider.get_auth_url() + return HttpResponseRedirect(auth_url) + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request), + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + +class GitHubCallbackSpaceEndpoint(View): + + def get(self, request): + code = request.GET.get("code") + state = request.GET.get("state") + base_host = request.session.get("host") + next_path = request.session.get("next_path") + + if state != request.session.get("state", ""): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "GITHUB_OAUTH_PROVIDER_ERROR" + ], + error_message="GITHUB_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host, + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + if not code: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "GITHUB_OAUTH_PROVIDER_ERROR" + ], + error_message="GITHUB_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host, + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + try: + provider = GitHubOAuthProvider( + request=request, + code=code, + ) + user = provider.authenticate() + # Login the user and record his device info + user_login(request=request, user=user) + # Process workspace and project invitations + # redirect to referer path + url = urljoin(base_host, str(next_path) if next_path else "/") + return HttpResponseRedirect(url) + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host, + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/space/google.py b/apiserver/plane/authentication/views/space/google.py new file mode 100644 index 000000000..86632ebb4 --- /dev/null +++ b/apiserver/plane/authentication/views/space/google.py @@ -0,0 +1,120 @@ +# Python imports +import uuid +from urllib.parse import urlencode, urljoin + +# Django import +from django.http import HttpResponseRedirect +from django.views import View + +# Module imports +from plane.authentication.provider.oauth.google import GoogleOAuthProvider +from plane.authentication.utils.login import user_login +from plane.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) + next_path = request.GET.get("next_path") + if next_path: + request.session["next_path"] = str(next_path) + + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INSTANCE_NOT_CONFIGURED" + ], + error_message="INSTANCE_NOT_CONFIGURED", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request), + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + try: + state = uuid.uuid4().hex + provider = GoogleOAuthProvider(request=request, state=state) + request.session["state"] = state + auth_url = provider.get_auth_url() + return HttpResponseRedirect(auth_url) + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request), + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + +class GoogleCallbackSpaceEndpoint(View): + def get(self, request): + code = request.GET.get("code") + state = request.GET.get("state") + base_host = request.session.get("host") + next_path = request.session.get("next_path") + + if state != request.session.get("state", ""): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "GOOGLE_OAUTH_PROVIDER_ERROR" + ], + error_message="GOOGLE_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host, + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + if not code: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "GOOGLE_OAUTH_PROVIDER_ERROR" + ], + error_message="GOOGLE_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = next_path + url = urljoin( + base_host, + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + try: + provider = GoogleOAuthProvider( + request=request, + code=code, + ) + user = provider.authenticate() + # Login the user and record his device info + user_login(request=request, user=user) + # redirect to referer path + url = urljoin( + base_host, str(next_path) if next_path else "/spaces" + ) + return HttpResponseRedirect(url) + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host, + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/space/magic.py b/apiserver/plane/authentication/views/space/magic.py new file mode 100644 index 000000000..390f6021d --- /dev/null +++ b/apiserver/plane/authentication/views/space/magic.py @@ -0,0 +1,196 @@ +# Python imports +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 + +# Third party imports +from rest_framework import status +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView + +# Module imports +from plane.authentication.provider.credentials.magic_code import ( + MagicCodeProvider, +) +from plane.authentication.utils.login import user_login +from plane.bgtasks.magic_link_code_task import magic_link +from plane.license.models import Instance +from plane.authentication.utils.host import base_host +from plane.db.models import User, Profile +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) + + +class MagicGenerateSpaceEndpoint(APIView): + + permission_classes = [ + AllowAny, + ] + + def post(self, request): + # Check if instance is configured + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INSTANCE_NOT_CONFIGURED" + ], + error_message="INSTANCE_NOT_CONFIGURED", + ) + return Response( + exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST + ) + + origin = base_host(request=request) + email = request.data.get("email", False) + try: + # Clean up the email + email = email.strip().lower() + validate_email(email) + adapter = MagicCodeProvider(request=request, key=email) + key, token = adapter.initiate() + # If the smtp is configured send through here + magic_link.delay(email, key, token, origin) + return Response({"key": str(key)}, status=status.HTTP_200_OK) + except AuthenticationException as e: + return Response( + e.get_error_dict(), + status=status.HTTP_400_BAD_REQUEST, + ) + + +class MagicSignInSpaceEndpoint(View): + + def post(self, request): + + # set the referer as session to redirect after login + code = request.POST.get("code", "").strip() + email = request.POST.get("email", "").strip().lower() + next_path = request.POST.get("next_path") + + if code == "" or email == "": + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED" + ], + error_message="MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request), + "spaces/accounts/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.", + } + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request), + "accounts/sign-in?" + 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) + # redirect to referer path + profile = Profile.objects.get(user=user) + if user.is_password_autoset and profile.is_onboarded: + path = "spaces/accounts/set-password" + else: + # Get the redirection path + path = str(next_path) if next_path else "spaces" + url = urljoin(base_host(request=request), path) + return HttpResponseRedirect(url) + + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request), + "spaces/accounts/sign-in?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + +class MagicSignUpSpaceEndpoint(View): + + def post(self, request): + + # set the referer as session to redirect after login + code = request.POST.get("code", "").strip() + email = request.POST.get("email", "").strip().lower() + next_path = request.POST.get("next_path") + + if code == "" or email == "": + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED" + ], + error_message="MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request), + "spaces/accounts/sign-in?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + if User.objects.filter(email=email).exists(): + 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), + ) + 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) + # redirect to referer path + url = urljoin( + base_host(request=request), + str(next_path) if next_path else "spaces", + ) + return HttpResponseRedirect(url) + + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request), + "spaces/accounts/sign-in?" + urlencode(params), + ) + return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/space/signout.py b/apiserver/plane/authentication/views/space/signout.py new file mode 100644 index 000000000..b993fb78c --- /dev/null +++ b/apiserver/plane/authentication/views/space/signout.py @@ -0,0 +1,34 @@ +# 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, user_ip +from plane.db.models import User + + +class SignOutAuthSpaceEndpoint(View): + + def post(self, request): + # Get user + try: + user = User.objects.get(pk=request.user.id) + user.last_logout_ip = user_ip(request=request) + user.last_logout_time = timezone.now() + user.save() + # Log the user out + logout(request) + url = urljoin( + base_host(request=request), + "accounts/sign-in?" + urlencode({"success": "true"}), + ) + return HttpResponseRedirect(url) + except Exception: + return HttpResponseRedirect( + base_host(request=request), "accounts/sign-in" + ) diff --git a/apiserver/plane/bgtasks/forgot_password_task.py b/apiserver/plane/bgtasks/forgot_password_task.py index b30c9311f..f830eb1e2 100644 --- a/apiserver/plane/bgtasks/forgot_password_task.py +++ b/apiserver/plane/bgtasks/forgot_password_task.py @@ -5,6 +5,7 @@ import logging from celery import shared_task # Django imports +# Third party imports from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string from django.utils.html import strip_tags diff --git a/apiserver/plane/bgtasks/magic_link_code_task.py b/apiserver/plane/bgtasks/magic_link_code_task.py index 4544e9889..7be0ae9f8 100644 --- a/apiserver/plane/bgtasks/magic_link_code_task.py +++ b/apiserver/plane/bgtasks/magic_link_code_task.py @@ -5,6 +5,7 @@ import logging from celery import shared_task # Django imports +# Third party imports from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string from django.utils.html import strip_tags diff --git a/apiserver/plane/db/management/commands/reset_password.py b/apiserver/plane/db/management/commands/reset_password.py index bca6c3560..9c137d320 100644 --- a/apiserver/plane/db/management/commands/reset_password.py +++ b/apiserver/plane/db/management/commands/reset_password.py @@ -2,7 +2,10 @@ import getpass # Django imports -from django.core.management import BaseCommand +from django.core.management import BaseCommand, CommandError + +# Third party imports +from zxcvbn import zxcvbn # Module imports from plane.db.models import User @@ -46,6 +49,13 @@ class Command(BaseCommand): self.stderr.write("Error: Blank passwords aren't allowed.") return + results = zxcvbn(password) + + if results["score"] < 3: + raise CommandError( + "Password is too common please set a complex password" + ) + # Set user password user.set_password(password) user.is_password_autoset = False diff --git a/apiserver/plane/db/migrations/0065_auto_20240415_0937.py b/apiserver/plane/db/migrations/0065_auto_20240415_0937.py new file mode 100644 index 000000000..e8e17a513 --- /dev/null +++ b/apiserver/plane/db/migrations/0065_auto_20240415_0937.py @@ -0,0 +1,270 @@ +# Generated by Django 4.2.10 on 2024-04-04 08:47 + +import uuid + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + +import plane.db.models.user + + +def migrate_user_profile(apps, schema_editor): + Profile = apps.get_model("db", "Profile") + User = apps.get_model("db", "User") + + Profile.objects.bulk_create( + [ + Profile( + user_id=user.get("id"), + theme=user.get("theme"), + is_tour_completed=user.get("is_tour_completed"), + use_case=user.get("use_case"), + is_onboarded=user.get("is_onboarded"), + last_workspace_id=user.get("last_workspace_id"), + billing_address_country=user.get("billing_address_country"), + billing_address=user.get("billing_address"), + has_billing_address=user.get("has_billing_address"), + ) + for user in User.objects.values( + "id", + "theme", + "is_tour_completed", + "onboarding_step", + "use_case", + "role", + "is_onboarded", + "last_workspace_id", + "billing_address_country", + "billing_address", + "has_billing_address", + ) + ], + batch_size=1000, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0064_auto_20240409_1134"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="avatar", + field=models.TextField(blank=True), + ), + migrations.CreateModel( + name="Session", + fields=[ + ( + "session_data", + models.TextField(verbose_name="session data"), + ), + ( + "expire_date", + models.DateTimeField( + db_index=True, verbose_name="expire date" + ), + ), + ( + "device_info", + models.JSONField(blank=True, default=None, null=True), + ), + ( + "session_key", + models.CharField( + max_length=128, primary_key=True, serialize=False + ), + ), + ("user_id", models.CharField(max_length=50, null=True)), + ], + options={ + "verbose_name": "session", + "verbose_name_plural": "sessions", + "db_table": "sessions", + "abstract": False, + }, + ), + migrations.CreateModel( + name="Profile", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("theme", models.JSONField(default=dict)), + ("is_tour_completed", models.BooleanField(default=False)), + ( + "onboarding_step", + models.JSONField( + default=plane.db.models.user.get_default_onboarding + ), + ), + ("use_case", models.TextField(blank=True, null=True)), + ( + "role", + models.CharField(blank=True, max_length=300, null=True), + ), + ("is_onboarded", models.BooleanField(default=False)), + ("last_workspace_id", models.UUIDField(null=True)), + ( + "billing_address_country", + models.CharField(default="INDIA", max_length=255), + ), + ("billing_address", models.JSONField(null=True)), + ("has_billing_address", models.BooleanField(default=False)), + ("company_name", models.CharField(blank=True, max_length=255)), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="profile", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Profile", + "verbose_name_plural": "Profiles", + "db_table": "profiles", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="Account", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("provider_account_id", models.CharField(max_length=255)), + ( + "provider", + models.CharField( + choices=[("google", "Google"), ("github", "Github")] + ), + ), + ("access_token", models.TextField()), + ("access_token_expired_at", models.DateTimeField(null=True)), + ("refresh_token", models.TextField(blank=True, null=True)), + ("refresh_token_expired_at", models.DateTimeField(null=True)), + ( + "last_connected_at", + models.DateTimeField(default=django.utils.timezone.now), + ), + ("metadata", models.JSONField(default=dict)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="accounts", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Account", + "verbose_name_plural": "Accounts", + "db_table": "accounts", + "ordering": ("-created_at",), + "unique_together": {("provider", "provider_account_id")}, + }, + ), + migrations.RunPython(migrate_user_profile), + migrations.RemoveField( + model_name="user", + name="billing_address", + ), + migrations.RemoveField( + model_name="user", + name="billing_address_country", + ), + migrations.RemoveField( + model_name="user", + name="has_billing_address", + ), + migrations.RemoveField( + model_name="user", + name="is_onboarded", + ), + migrations.RemoveField( + model_name="user", + name="is_tour_completed", + ), + migrations.RemoveField( + model_name="user", + name="last_workspace_id", + ), + migrations.RemoveField( + model_name="user", + name="my_issues_prop", + ), + migrations.RemoveField( + model_name="user", + name="onboarding_step", + ), + migrations.RemoveField( + model_name="user", + name="role", + ), + migrations.RemoveField( + model_name="user", + name="theme", + ), + migrations.RemoveField( + model_name="user", + name="use_case", + ), + migrations.AddField( + model_name="globalview", + name="logo_props", + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name="page", + name="logo_props", + field=models.JSONField(default=dict), + ), + ] diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index daa793c37..2dc6d7909 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -1,78 +1,80 @@ -from .base import BaseModel - -from .user import User - -from .workspace import ( - Workspace, - WorkspaceMember, - Team, - WorkspaceMemberInvite, - TeamMember, - WorkspaceTheme, - WorkspaceUserProperties, - WorkspaceBaseModel, -) - -from .project import ( - Project, - ProjectMember, - ProjectBaseModel, - ProjectMemberInvite, - ProjectIdentifier, - ProjectFavorite, - ProjectDeployBoard, - ProjectPublicMember, -) - -from .issue import ( - Issue, - IssueActivity, - IssueProperty, - IssueComment, - IssueLabel, - IssueAssignee, - Label, - IssueBlocker, - IssueRelation, - IssueMention, - IssueLink, - IssueSequence, - IssueAttachment, - IssueSubscriber, - IssueReaction, - CommentReaction, - IssueVote, -) - +from .analytic import AnalyticView +from .api import APIActivityLog, APIToken from .asset import FileAsset - -from .social_connection import SocialLoginConnection - -from .state import State - -from .cycle import Cycle, CycleIssue, CycleFavorite, CycleUserProperties - -from .view import GlobalView, IssueView, IssueViewFavorite - -from .module import ( - Module, - ModuleMember, - ModuleIssue, - ModuleLink, - ModuleFavorite, - ModuleUserProperties, -) - -from .api import APIToken, APIActivityLog - +from .base import BaseModel +from .cycle import Cycle, CycleFavorite, CycleIssue, CycleUserProperties +from .dashboard import Dashboard, DashboardWidget, Widget +from .estimate import Estimate, EstimatePoint +from .exporter import ExporterHistory +from .importer import Importer +from .inbox import Inbox, InboxIssue from .integration import ( - WorkspaceIntegration, - Integration, + GithubCommentSync, + GithubIssueSync, GithubRepository, GithubRepositorySync, - GithubIssueSync, - GithubCommentSync, + Integration, SlackProjectSync, + WorkspaceIntegration, +) +from .issue import ( + CommentReaction, + Issue, + IssueActivity, + IssueAssignee, + IssueAttachment, + IssueBlocker, + IssueComment, + IssueLabel, + IssueLink, + IssueMention, + IssueProperty, + IssueReaction, + IssueRelation, + IssueSequence, + IssueSubscriber, + IssueVote, + Label, +) +from .module import ( + Module, + ModuleFavorite, + ModuleIssue, + ModuleLink, + ModuleMember, + ModuleUserProperties, +) +from .notification import ( + EmailNotificationLog, + Notification, + UserNotificationPreference, +) +from .page import Page, PageFavorite, PageLabel, PageLog +from .project import ( + Project, + ProjectBaseModel, + ProjectDeployBoard, + ProjectFavorite, + ProjectIdentifier, + ProjectMember, + ProjectMemberInvite, + ProjectPublicMember, +) +from .session import Session +from .social_connection import SocialLoginConnection +from .state import State +from .user import Account, Profile, User +from .view import GlobalView, IssueView, IssueViewFavorite +from .webhook import Webhook, WebhookLog +from .workspace import ( + Team, + TeamMember, + Workspace, + WorkspaceBaseModel, + WorkspaceMember, + WorkspaceMemberInvite, + WorkspaceTheme, + WorkspaceUserProperties, ) from .importer import Importer diff --git a/apiserver/plane/db/models/asset.py b/apiserver/plane/db/models/asset.py index 713508613..7dd2f2c91 100644 --- a/apiserver/plane/db/models/asset.py +++ b/apiserver/plane/db/models/asset.py @@ -1,13 +1,14 @@ # Python imports from uuid import uuid4 +from django.conf import settings +from django.core.exceptions import ValidationError + # Django import from django.db import models -from django.core.exceptions import ValidationError -from django.conf import settings # Module import -from . import BaseModel +from .base import BaseModel def get_upload_path(instance, filename): diff --git a/apiserver/plane/db/models/cycle.py b/apiserver/plane/db/models/cycle.py index 15a8251d7..1b4e8e75b 100644 --- a/apiserver/plane/db/models/cycle.py +++ b/apiserver/plane/db/models/cycle.py @@ -1,9 +1,9 @@ # Django imports -from django.db import models from django.conf import settings +from django.db import models # Module imports -from . import ProjectBaseModel +from .project import ProjectBaseModel def get_default_filters(): diff --git a/apiserver/plane/db/models/dashboard.py b/apiserver/plane/db/models/dashboard.py index d07a70728..7d483060f 100644 --- a/apiserver/plane/db/models/dashboard.py +++ b/apiserver/plane/db/models/dashboard.py @@ -4,8 +4,8 @@ import uuid from django.db import models # Module imports -from . import BaseModel from ..mixins import TimeAuditModel +from .base import BaseModel class Dashboard(BaseModel): diff --git a/apiserver/plane/db/models/estimate.py b/apiserver/plane/db/models/estimate.py index bb57e788c..5a783f9b9 100644 --- a/apiserver/plane/db/models/estimate.py +++ b/apiserver/plane/db/models/estimate.py @@ -1,9 +1,9 @@ # Django imports +from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models -from django.core.validators import MinValueValidator, MaxValueValidator # Module imports -from . import ProjectBaseModel +from .project import ProjectBaseModel class Estimate(ProjectBaseModel): diff --git a/apiserver/plane/db/models/exporter.py b/apiserver/plane/db/models/exporter.py index d427eb0f6..9790db68d 100644 --- a/apiserver/plane/db/models/exporter.py +++ b/apiserver/plane/db/models/exporter.py @@ -3,13 +3,14 @@ import uuid # Python imports from uuid import uuid4 -# Django imports -from django.db import models from django.conf import settings from django.contrib.postgres.fields import ArrayField +# Django imports +from django.db import models + # Module imports -from . import BaseModel +from .base import BaseModel def generate_token(): diff --git a/apiserver/plane/db/models/importer.py b/apiserver/plane/db/models/importer.py index 651927458..ebc7571d5 100644 --- a/apiserver/plane/db/models/importer.py +++ b/apiserver/plane/db/models/importer.py @@ -1,9 +1,9 @@ # Django imports -from django.db import models from django.conf import settings +from django.db import models # Module imports -from . import ProjectBaseModel +from .project import ProjectBaseModel class Importer(ProjectBaseModel): diff --git a/apiserver/plane/db/models/inbox.py b/apiserver/plane/db/models/inbox.py index 809a11821..6d72029b6 100644 --- a/apiserver/plane/db/models/inbox.py +++ b/apiserver/plane/db/models/inbox.py @@ -2,7 +2,7 @@ from django.db import models # Module imports -from plane.db.models import ProjectBaseModel +from plane.db.models.project import ProjectBaseModel class Inbox(ProjectBaseModel): diff --git a/apiserver/plane/db/models/integration/github.py b/apiserver/plane/db/models/integration/github.py index 6a00dc690..9e4294175 100644 --- a/apiserver/plane/db/models/integration/github.py +++ b/apiserver/plane/db/models/integration/github.py @@ -4,7 +4,7 @@ from django.db import models # Module imports -from plane.db.models import ProjectBaseModel +from plane.db.models.project import ProjectBaseModel class GithubRepository(ProjectBaseModel): diff --git a/apiserver/plane/db/models/integration/slack.py b/apiserver/plane/db/models/integration/slack.py index 1f07179b7..94d5d7d83 100644 --- a/apiserver/plane/db/models/integration/slack.py +++ b/apiserver/plane/db/models/integration/slack.py @@ -4,7 +4,7 @@ from django.db import models # Module imports -from plane.db.models import ProjectBaseModel +from plane.db.models.project import ProjectBaseModel class SlackProjectSync(ProjectBaseModel): diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index 01a43abca..e3d1e62a7 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -2,19 +2,20 @@ from uuid import uuid4 # Django imports -from django.contrib.postgres.fields import ArrayField -from django.db import models from django.conf import settings +from django.contrib.postgres.fields import ArrayField +from django.core.exceptions import ValidationError +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver -from django.core.validators import MinValueValidator, MaxValueValidator -from django.core.exceptions import ValidationError from django.utils import timezone # Module imports -from . import ProjectBaseModel from plane.utils.html_processor import strip_tags +from .project import ProjectBaseModel + def get_default_properties(): return { diff --git a/apiserver/plane/db/models/module.py b/apiserver/plane/db/models/module.py index b201e4d7f..7e58088dc 100644 --- a/apiserver/plane/db/models/module.py +++ b/apiserver/plane/db/models/module.py @@ -1,9 +1,9 @@ # Django imports -from django.db import models from django.conf import settings +from django.db import models # Module imports -from . import ProjectBaseModel +from .project import ProjectBaseModel def get_default_filters(): diff --git a/apiserver/plane/db/models/notification.py b/apiserver/plane/db/models/notification.py index 9138ece9f..33241e05d 100644 --- a/apiserver/plane/db/models/notification.py +++ b/apiserver/plane/db/models/notification.py @@ -1,9 +1,10 @@ # Django imports -from django.db import models from django.conf import settings +from django.db import models # Module imports -from . import BaseModel +from .base import BaseModel + class Notification(BaseModel): diff --git a/apiserver/plane/db/models/page.py b/apiserver/plane/db/models/page.py index da7e050bb..6a06d8d44 100644 --- a/apiserver/plane/db/models/page.py +++ b/apiserver/plane/db/models/page.py @@ -1,13 +1,15 @@ import uuid +from django.conf import settings + # Django imports from django.db import models -from django.conf import settings # Module imports -from . import ProjectBaseModel from plane.utils.html_processor import strip_tags +from .project import ProjectBaseModel + def get_view_props(): return {"full_width": False} @@ -40,6 +42,7 @@ 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) class Meta: verbose_name = "Page" @@ -121,7 +124,7 @@ class PageBlock(ProjectBaseModel): if self.completed_at and self.issue: try: - from plane.db.models import State, Issue + from plane.db.models import Issue, State completed_state = State.objects.filter( group="completed", project=self.project diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index db5ebf33b..49fca1323 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -2,15 +2,15 @@ from uuid import uuid4 # Django imports -from django.db import models from django.conf import settings -from django.core.validators import MinValueValidator, MaxValueValidator +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models # Modeule imports from plane.db.mixins import AuditModel # Module imports -from . import BaseModel +from .base import BaseModel ROLE_CHOICES = ( (20, "Admin"), diff --git a/apiserver/plane/db/models/session.py b/apiserver/plane/db/models/session.py new file mode 100644 index 000000000..95e8e0b7d --- /dev/null +++ b/apiserver/plane/db/models/session.py @@ -0,0 +1,65 @@ +# Python imports +import string + +# Django imports +from django.contrib.sessions.backends.db import SessionStore as DBSessionStore +from django.contrib.sessions.base_session import AbstractBaseSession +from django.db import models +from django.utils.crypto import get_random_string + +VALID_KEY_CHARS = string.ascii_lowercase + string.digits + + +class Session(AbstractBaseSession): + device_info = models.JSONField( + null=True, + blank=True, + default=None, + ) + session_key = models.CharField( + max_length=128, + primary_key=True, + ) + user_id = models.CharField( + null=True, + max_length=50, + ) + + @classmethod + def get_session_store_class(cls): + return SessionStore + + class Meta(AbstractBaseSession.Meta): + db_table = "sessions" + + +class SessionStore(DBSessionStore): + + @classmethod + def get_model_class(cls): + return Session + + def _get_new_session_key(self): + """ + Return a new session key that is not present in the current backend. + Override this method to use a custom session key generation mechanism. + """ + while True: + session_key = get_random_string(128, VALID_KEY_CHARS) + if not self.exists(session_key): + return session_key + + def create_model_instance(self, data): + obj = super().create_model_instance(data) + try: + user_id = data.get("_auth_user_id") + except (ValueError, TypeError): + user_id = None + obj.user_id = user_id + + # Save the device info + device_info = data.get("device_info") + obj.device_info = ( + device_info if isinstance(device_info, dict) else None + ) + return obj diff --git a/apiserver/plane/db/models/social_connection.py b/apiserver/plane/db/models/social_connection.py index 73028e419..96fbbb967 100644 --- a/apiserver/plane/db/models/social_connection.py +++ b/apiserver/plane/db/models/social_connection.py @@ -1,10 +1,10 @@ # Django imports -from django.db import models from django.conf import settings +from django.db import models from django.utils import timezone # Module import -from . import BaseModel +from .base import BaseModel class SocialLoginConnection(BaseModel): diff --git a/apiserver/plane/db/models/state.py b/apiserver/plane/db/models/state.py index 28e3b25a1..36e053e22 100644 --- a/apiserver/plane/db/models/state.py +++ b/apiserver/plane/db/models/state.py @@ -3,7 +3,7 @@ from django.db import models from django.template.defaultfilters import slugify # Module imports -from . import ProjectBaseModel +from .project import ProjectBaseModel class State(ProjectBaseModel): diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py index 5f932d2ea..f35520d8f 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -16,6 +16,9 @@ from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone +# Module imports +from ..mixins import TimeAuditModel + def get_default_onboarding(): return { @@ -35,15 +38,17 @@ class User(AbstractBaseUser, PermissionsMixin): primary_key=True, ) username = models.CharField(max_length=128, unique=True) - # user fields mobile_number = models.CharField(max_length=255, blank=True, null=True) email = models.CharField( max_length=255, null=True, blank=True, unique=True ) + + # identity + display_name = models.CharField(max_length=255, default="") first_name = models.CharField(max_length=255, blank=True) last_name = models.CharField(max_length=255, blank=True) - avatar = models.CharField(max_length=255, blank=True) + avatar = models.TextField(blank=True) cover_image = models.URLField(blank=True, null=True, max_length=800) # tracking metrics @@ -67,19 +72,10 @@ class User(AbstractBaseUser, PermissionsMixin): is_staff = models.BooleanField(default=False) is_email_verified = models.BooleanField(default=False) is_password_autoset = models.BooleanField(default=False) - is_onboarded = models.BooleanField(default=False) + # random token generated token = models.CharField(max_length=64, blank=True) - billing_address_country = models.CharField(max_length=255, default="INDIA") - billing_address = models.JSONField(null=True) - has_billing_address = models.BooleanField(default=False) - - USER_TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones)) - user_timezone = models.CharField( - max_length=255, default="UTC", choices=USER_TIMEZONE_CHOICES - ) - last_active = models.DateTimeField(default=timezone.now, null=True) last_login_time = models.DateTimeField(null=True) last_logout_time = models.DateTimeField(null=True) @@ -91,18 +87,17 @@ class User(AbstractBaseUser, PermissionsMixin): ) last_login_uagent = models.TextField(blank=True) token_updated_at = models.DateTimeField(null=True) - last_workspace_id = models.UUIDField(null=True) - my_issues_prop = models.JSONField(null=True) - role = models.CharField(max_length=300, null=True, blank=True) + # my_issues_prop = models.JSONField(null=True) + is_bot = models.BooleanField(default=False) - theme = models.JSONField(default=dict) - display_name = models.CharField(max_length=255, default="") - is_tour_completed = models.BooleanField(default=False) - onboarding_step = models.JSONField(default=get_default_onboarding) - use_case = models.TextField(blank=True, null=True) + + # timezone + USER_TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones)) + user_timezone = models.CharField( + max_length=255, default="UTC", choices=USER_TIMEZONE_CHOICES + ) USERNAME_FIELD = "email" - REQUIRED_FIELDS = ["username"] objects = UserManager() @@ -139,6 +134,71 @@ class User(AbstractBaseUser, PermissionsMixin): super(User, self).save(*args, **kwargs) +class Profile(TimeAuditModel): + id = models.UUIDField( + default=uuid.uuid4, + unique=True, + editable=False, + db_index=True, + primary_key=True, + ) + # User + user = models.OneToOneField( + "db.User", on_delete=models.CASCADE, related_name="profile" + ) + # General + theme = models.JSONField(default=dict) + # Onboarding + is_tour_completed = models.BooleanField(default=False) + onboarding_step = models.JSONField(default=get_default_onboarding) + use_case = models.TextField(blank=True, null=True) + role = models.CharField(max_length=300, null=True, blank=True) # job role + is_onboarded = models.BooleanField(default=False) + # Last visited workspace + last_workspace_id = models.UUIDField(null=True) + # address data + billing_address_country = models.CharField(max_length=255, default="INDIA") + billing_address = models.JSONField(null=True) + has_billing_address = models.BooleanField(default=False) + company_name = models.CharField(max_length=255, blank=True) + + class Meta: + verbose_name = "Profile" + verbose_name_plural = "Profiles" + db_table = "profiles" + ordering = ("-created_at",) + + +class Account(TimeAuditModel): + id = models.UUIDField( + default=uuid.uuid4, + unique=True, + editable=False, + db_index=True, + primary_key=True, + ) + user = models.ForeignKey( + "db.User", on_delete=models.CASCADE, related_name="accounts" + ) + provider_account_id = models.CharField(max_length=255) + provider = models.CharField( + choices=(("google", "Google"), ("github", "Github")), + ) + access_token = models.TextField() + access_token_expired_at = models.DateTimeField(null=True) + refresh_token = models.TextField(null=True, blank=True) + refresh_token_expired_at = models.DateTimeField(null=True) + last_connected_at = models.DateTimeField(default=timezone.now) + metadata = models.JSONField(default=dict) + + class Meta: + unique_together = ["provider", "provider_account_id"] + verbose_name = "Account" + verbose_name_plural = "Accounts" + db_table = "accounts" + ordering = ("-created_at",) + + @receiver(post_save, sender=User) def create_user_notification(sender, instance, created, **kwargs): # create preferences diff --git a/apiserver/plane/db/models/view.py b/apiserver/plane/db/models/view.py index 13500b5a4..87f0899c3 100644 --- a/apiserver/plane/db/models/view.py +++ b/apiserver/plane/db/models/view.py @@ -1,9 +1,11 @@ # Django imports -from django.db import models from django.conf import settings +from django.db import models # Module import -from . import ProjectBaseModel, BaseModel, WorkspaceBaseModel +from .base import BaseModel +from .project import ProjectBaseModel +from .workspace import WorkspaceBaseModel def get_default_filters(): @@ -62,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" @@ -84,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 7e5d6d90b..56e136126 100644 --- a/apiserver/plane/db/models/workspace.py +++ b/apiserver/plane/db/models/workspace.py @@ -1,11 +1,10 @@ # Django imports -from django.db import models from django.conf import settings from django.core.exceptions import ValidationError +from django.db import models # Module imports -from . import BaseModel - +from .base import BaseModel ROLE_CHOICES = ( (20, "Owner"), diff --git a/apiserver/plane/license/api/serializers/__init__.py b/apiserver/plane/license/api/serializers/__init__.py index e6beda0e9..7b9cb676f 100644 --- a/apiserver/plane/license/api/serializers/__init__.py +++ b/apiserver/plane/license/api/serializers/__init__.py @@ -1,5 +1,6 @@ from .instance import ( InstanceSerializer, - InstanceAdminSerializer, - InstanceConfigurationSerializer, ) + +from .configuration import InstanceConfigurationSerializer +from .admin import InstanceAdminSerializer, InstanceAdminMeSerializer diff --git a/apiserver/plane/license/api/serializers/admin.py b/apiserver/plane/license/api/serializers/admin.py new file mode 100644 index 000000000..848e94ef7 --- /dev/null +++ b/apiserver/plane/license/api/serializers/admin.py @@ -0,0 +1,41 @@ +# Module imports +from .base import BaseSerializer +from plane.db.models import User +from plane.app.serializers import UserAdminLiteSerializer +from plane.license.models import InstanceAdmin + + +class InstanceAdminMeSerializer(BaseSerializer): + class Meta: + model = User + fields = [ + "id", + "avatar", + "cover_image", + "date_joined", + "display_name", + "email", + "first_name", + "last_name", + "is_active", + "is_bot", + "is_email_verified", + "user_timezone", + "username", + "is_password_autoset", + "is_email_verified", + ] + read_only_fields = fields + + +class InstanceAdminSerializer(BaseSerializer): + user_detail = UserAdminLiteSerializer(source="user", read_only=True) + + class Meta: + model = InstanceAdmin + fields = "__all__" + read_only_fields = [ + "id", + "instance", + "user", + ] diff --git a/apiserver/plane/license/api/serializers/base.py b/apiserver/plane/license/api/serializers/base.py new file mode 100644 index 000000000..0c6bba468 --- /dev/null +++ b/apiserver/plane/license/api/serializers/base.py @@ -0,0 +1,5 @@ +from rest_framework import serializers + + +class BaseSerializer(serializers.ModelSerializer): + id = serializers.PrimaryKeyRelatedField(read_only=True) diff --git a/apiserver/plane/license/api/serializers/configuration.py b/apiserver/plane/license/api/serializers/configuration.py new file mode 100644 index 000000000..1766f2113 --- /dev/null +++ b/apiserver/plane/license/api/serializers/configuration.py @@ -0,0 +1,17 @@ +from .base import BaseSerializer +from plane.license.models import InstanceConfiguration +from plane.license.utils.encryption import decrypt_data + + +class InstanceConfigurationSerializer(BaseSerializer): + class Meta: + model = InstanceConfiguration + fields = "__all__" + + def to_representation(self, instance): + data = super().to_representation(instance) + # Decrypt secrets value + if instance.is_encrypted and instance.value is not None: + data["value"] = decrypt_data(instance.value) + + return data diff --git a/apiserver/plane/license/api/serializers/instance.py b/apiserver/plane/license/api/serializers/instance.py index 8a99acbae..86aef1a3a 100644 --- a/apiserver/plane/license/api/serializers/instance.py +++ b/apiserver/plane/license/api/serializers/instance.py @@ -1,8 +1,7 @@ # Module imports -from plane.license.models import Instance, InstanceAdmin, InstanceConfiguration +from plane.license.models import Instance from plane.app.serializers import BaseSerializer from plane.app.serializers import UserAdminLiteSerializer -from plane.license.utils.encryption import decrypt_data class InstanceSerializer(BaseSerializer): @@ -23,30 +22,3 @@ class InstanceSerializer(BaseSerializer): "last_checked_at", "is_setup_done", ] - - -class InstanceAdminSerializer(BaseSerializer): - user_detail = UserAdminLiteSerializer(source="user", read_only=True) - - class Meta: - model = InstanceAdmin - fields = "__all__" - read_only_fields = [ - "id", - "instance", - "user", - ] - - -class InstanceConfigurationSerializer(BaseSerializer): - class Meta: - model = InstanceConfiguration - fields = "__all__" - - def to_representation(self, instance): - data = super().to_representation(instance) - # Decrypt secrets value - if instance.is_encrypted and instance.value is not None: - data["value"] = decrypt_data(instance.value) - - return data diff --git a/apiserver/plane/license/api/views/__init__.py b/apiserver/plane/license/api/views/__init__.py index 3a66c94c5..cddaff0eb 100644 --- a/apiserver/plane/license/api/views/__init__.py +++ b/apiserver/plane/license/api/views/__init__.py @@ -1,7 +1,19 @@ from .instance import ( InstanceEndpoint, - InstanceAdminEndpoint, - InstanceConfigurationEndpoint, - InstanceAdminSignInEndpoint, SignUpScreenVisitedEndpoint, ) + + +from .configuration import ( + EmailCredentialCheckEndpoint, + InstanceConfigurationEndpoint, +) + + +from .admin import ( + InstanceAdminEndpoint, + InstanceAdminSignInEndpoint, + InstanceAdminSignUpEndpoint, + InstanceAdminUserMeEndpoint, + InstanceAdminSignOutEndpoint, +) diff --git a/apiserver/plane/license/api/views/admin.py b/apiserver/plane/license/api/views/admin.py new file mode 100644 index 000000000..8833c7a7c --- /dev/null +++ b/apiserver/plane/license/api/views/admin.py @@ -0,0 +1,421 @@ +# Python imports +from urllib.parse import urlencode, urljoin +import uuid +from zxcvbn import zxcvbn + +# Django imports +from django.http import HttpResponseRedirect +from django.views import View +from django.core.validators import validate_email +from django.core.exceptions import ValidationError +from django.utils import timezone +from django.contrib.auth.hashers import make_password +from django.contrib.auth import logout + +# Third party imports +from rest_framework.response import Response +from rest_framework import status +from rest_framework.permissions import AllowAny + +# Module imports +from .base import BaseAPIView +from plane.license.api.permissions import InstanceAdminPermission +from plane.license.api.serializers import ( + InstanceAdminMeSerializer, + InstanceAdminSerializer, +) +from plane.license.models import Instance, InstanceAdmin +from plane.db.models import User, Profile +from plane.utils.cache import cache_response, invalidate_cache +from plane.authentication.utils.login import user_login +from plane.authentication.utils.host import base_host, user_ip +from plane.authentication.adapter.error import ( + AUTHENTICATION_ERROR_CODES, + AuthenticationException, +) + + +class InstanceAdminEndpoint(BaseAPIView): + permission_classes = [ + InstanceAdminPermission, + ] + + @invalidate_cache(path="/api/instances/", user=False) + # Create an instance admin + def post(self, request): + email = request.data.get("email", False) + role = request.data.get("role", 20) + + if not email: + return Response( + {"error": "Email is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + instance = Instance.objects.first() + if instance is None: + return Response( + {"error": "Instance is not registered yet"}, + status=status.HTTP_403_FORBIDDEN, + ) + + # Fetch the user + user = User.objects.get(email=email) + + instance_admin = InstanceAdmin.objects.create( + instance=instance, + user=user, + role=role, + ) + serializer = InstanceAdminSerializer(instance_admin) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + @cache_response(60 * 60 * 2, user=False) + def get(self, request): + instance = Instance.objects.first() + if instance is None: + return Response( + {"error": "Instance is not registered yet"}, + status=status.HTTP_403_FORBIDDEN, + ) + instance_admins = InstanceAdmin.objects.filter(instance=instance) + serializer = InstanceAdminSerializer(instance_admins, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + @invalidate_cache(path="/api/instances/", user=False) + def delete(self, request, pk): + instance = Instance.objects.first() + InstanceAdmin.objects.filter(instance=instance, pk=pk).delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class InstanceAdminSignUpEndpoint(View): + permission_classes = [ + AllowAny, + ] + + @invalidate_cache(path="/api/instances/", user=False) + def post(self, request): + # Check instance first + instance = Instance.objects.first() + if instance is None: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INSTANCE_NOT_CONFIGURED" + ], + error_message="INSTANCE_NOT_CONFIGURED", + ) + url = urljoin( + base_host(request=request), + "god-mode/setup?" + 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(exc.get_error_dict()), + ) + return HttpResponseRedirect(url) + + # Get the email and password from all the user + email = request.POST.get("email", False) + password = request.POST.get("password", False) + first_name = request.POST.get("first_name", False) + last_name = request.POST.get("last_name", "") + company_name = request.POST.get("company_name", "") + is_telemetry_enabled = request.POST.get("is_telemetry_enabled", True) + + # return error if the email and password is not present + if not email or not password or not first_name: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME" + ], + error_message="REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME", + payload={ + "email": email, + "first_name": first_name, + "last_name": last_name, + "company_name": company_name, + "is_telemetry_enabled": is_telemetry_enabled, + }, + ) + url = urljoin( + base_host(request=request), + "god-mode/setup?" + urlencode(exc.get_error_dict()), + ) + return HttpResponseRedirect(url) + + # Validate the email + email = email.strip().lower() + try: + validate_email(email) + except ValidationError: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_ADMIN_EMAIL"], + error_message="INVALID_ADMIN_EMAIL", + payload={ + "email": email, + "first_name": first_name, + "last_name": last_name, + "company_name": company_name, + "is_telemetry_enabled": is_telemetry_enabled, + }, + ) + url = urljoin( + base_host(request=request), + "god-mode/setup?" + 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(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(exc.get_error_dict()), + ) + return HttpResponseRedirect(url) + + user = User.objects.create( + first_name=first_name, + last_name=last_name, + email=email, + username=uuid.uuid4().hex, + password=make_password(password), + is_password_autoset=False, + ) + _ = Profile.objects.create(user=user, company_name=company_name) + # settings last active for the user + user.is_active = True + user.last_active = timezone.now() + user.last_login_time = timezone.now() + user.last_login_ip = request.META.get("REMOTE_ADDR") + user.last_login_uagent = request.META.get("HTTP_USER_AGENT") + user.token_updated_at = timezone.now() + user.save() + + # Register the user as an instance admin + _ = InstanceAdmin.objects.create( + user=user, + instance=instance, + ) + # Make the setup flag True + instance.is_setup_done = True + instance.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") + return HttpResponseRedirect(url) + + +class InstanceAdminSignInEndpoint(View): + permission_classes = [ + AllowAny, + ] + + @invalidate_cache(path="/api/instances/", user=False) + def post(self, request): + # Check instance first + instance = Instance.objects.first() + if instance is None: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INSTANCE_NOT_CONFIGURED" + ], + error_message="INSTANCE_NOT_CONFIGURED", + ) + url = urljoin( + base_host(request=request), + "god-mode/login?" + urlencode(exc.get_error_dict()), + ) + return HttpResponseRedirect(url) + + # Get email and password + email = request.POST.get("email", False) + password = request.POST.get("password", False) + + # return error if the email and password is not present + if not email or not password: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "REQUIRED_ADMIN_EMAIL_PASSWORD" + ], + error_message="REQUIRED_ADMIN_EMAIL_PASSWORD", + payload={ + "email": email, + }, + ) + url = urljoin( + base_host(request=request), + "god-mode/login?" + urlencode(exc.get_error_dict()), + ) + return HttpResponseRedirect(url) + + # Validate the email + email = email.strip().lower() + try: + validate_email(email) + except ValidationError: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_ADMIN_EMAIL"], + error_message="INVALID_ADMIN_EMAIL", + payload={ + "email": email, + }, + ) + url = urljoin( + base_host(request=request), + "god-mode/login?" + urlencode(exc.get_error_dict()), + ) + return HttpResponseRedirect(url) + + # Fetch the user + user = User.objects.filter(email=email).first() + + # 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(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(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(exc.get_error_dict()), + ) + return HttpResponseRedirect(url) + # settings last active for the user + user.is_active = True + user.last_active = timezone.now() + user.last_login_time = timezone.now() + user.last_login_ip = request.META.get("REMOTE_ADDR") + user.last_login_uagent = request.META.get("HTTP_USER_AGENT") + user.token_updated_at = timezone.now() + user.save() + + # get tokens for user + user_login(request=request, user=user) + url = urljoin(base_host(request=request), "god-mode/general") + return HttpResponseRedirect(url) + + +class InstanceAdminUserMeEndpoint(BaseAPIView): + + permission_classes = [ + InstanceAdminPermission, + ] + + def get(self, request): + serializer = InstanceAdminMeSerializer(request.user) + return Response( + serializer.data, + status=status.HTTP_200_OK, + ) + + +class InstanceAdminSignOutEndpoint(View): + + permission_classes = [ + InstanceAdminPermission, + ] + + def post(self, request): + # Get user + try: + user = User.objects.get(pk=request.user.id) + user.last_logout_ip = user_ip(request=request) + user.last_logout_time = timezone.now() + user.save() + # Log the user out + logout(request) + url = urljoin( + base_host(request=request), + "accounts/sign-in?" + urlencode({"success": "true"}), + ) + return HttpResponseRedirect(url) + except Exception: + return HttpResponseRedirect( + base_host(request=request), "accounts/sign-in" + ) diff --git a/apiserver/plane/license/api/views/base.py b/apiserver/plane/license/api/views/base.py new file mode 100644 index 000000000..7e367f941 --- /dev/null +++ b/apiserver/plane/license/api/views/base.py @@ -0,0 +1,132 @@ +# Python imports +import zoneinfo +from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.db import IntegrityError + +# Django imports +from django.utils import timezone +from django_filters.rest_framework import DjangoFilterBackend + +# Third part imports +from rest_framework import status +from rest_framework.filters import SearchFilter +from rest_framework.response import Response +from rest_framework.views import APIView + +# Module imports +from plane.license.api.permissions import InstanceAdminPermission +from plane.authentication.session import BaseSessionAuthentication +from plane.utils.exception_logger import log_exception +from plane.utils.paginator import BasePaginator + + +class TimezoneMixin: + """ + This enables timezone conversion according + to the user set timezone + """ + + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + if request.user.is_authenticated: + timezone.activate(zoneinfo.ZoneInfo(request.user.user_timezone)) + else: + timezone.deactivate() + + +class BaseAPIView(TimezoneMixin, APIView, BasePaginator): + permission_classes = [ + InstanceAdminPermission, + ] + + filter_backends = ( + DjangoFilterBackend, + SearchFilter, + ) + + authentication_classes = [ + BaseSessionAuthentication, + ] + + filterset_fields = [] + + search_fields = [] + + def filter_queryset(self, queryset): + for backend in list(self.filter_backends): + queryset = backend().filter_queryset(self.request, queryset, self) + return queryset + + def handle_exception(self, exc): + """ + Handle any exception that occurs, by returning an appropriate response, + or re-raising the error. + """ + try: + response = super().handle_exception(exc) + return response + except Exception as e: + if isinstance(e, IntegrityError): + return Response( + {"error": "The payload is not valid"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if isinstance(e, ValidationError): + return Response( + {"error": "Please provide valid detail"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if isinstance(e, ObjectDoesNotExist): + return Response( + {"error": "The required object does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + + if isinstance(e, KeyError): + return Response( + {"error": "The required key does not exist."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + log_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + def dispatch(self, request, *args, **kwargs): + try: + response = super().dispatch(request, *args, **kwargs) + + if settings.DEBUG: + from django.db import connection + + print( + f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}" + ) + return response + + except Exception as exc: + response = self.handle_exception(exc) + return exc + + @property + def fields(self): + fields = [ + field + for field in self.request.GET.get("fields", "").split(",") + if field + ] + return fields if fields else None + + @property + def expand(self): + expand = [ + expand + for expand in self.request.GET.get("expand", "").split(",") + if expand + ] + return expand if expand else None diff --git a/apiserver/plane/license/api/views/configuration.py b/apiserver/plane/license/api/views/configuration.py new file mode 100644 index 000000000..06f53b753 --- /dev/null +++ b/apiserver/plane/license/api/views/configuration.py @@ -0,0 +1,168 @@ +# Python imports +from smtplib import ( + SMTPAuthenticationError, + SMTPConnectError, + SMTPRecipientsRefused, + SMTPSenderRefused, + SMTPServerDisconnected, +) + +# Django imports +from django.core.mail import ( + BadHeaderError, + EmailMultiAlternatives, + get_connection, +) + +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from .base import BaseAPIView +from plane.license.api.permissions import InstanceAdminPermission +from plane.license.models import InstanceConfiguration +from plane.license.api.serializers import InstanceConfigurationSerializer +from plane.license.utils.encryption import encrypt_data +from plane.utils.cache import cache_response, invalidate_cache +from plane.license.utils.instance_value import ( + get_email_configuration, +) + + +class InstanceConfigurationEndpoint(BaseAPIView): + permission_classes = [ + InstanceAdminPermission, + ] + + @cache_response(60 * 60 * 2, user=False) + def get(self, request): + instance_configurations = InstanceConfiguration.objects.all() + serializer = InstanceConfigurationSerializer( + instance_configurations, many=True + ) + return Response(serializer.data, status=status.HTTP_200_OK) + + @invalidate_cache(path="/api/instances/configurations/", user=False) + @invalidate_cache(path="/api/instances/", user=False) + def patch(self, request): + configurations = InstanceConfiguration.objects.filter( + key__in=request.data.keys() + ) + + bulk_configurations = [] + for configuration in configurations: + value = request.data.get(configuration.key, configuration.value) + if configuration.is_encrypted: + configuration.value = encrypt_data(value) + else: + configuration.value = value + bulk_configurations.append(configuration) + + InstanceConfiguration.objects.bulk_update( + bulk_configurations, ["value"], batch_size=100 + ) + + serializer = InstanceConfigurationSerializer(configurations, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class EmailCredentialCheckEndpoint(BaseAPIView): + + def post(self, request): + receiver_email = request.data.get("receiver_email", False) + if not receiver_email: + return Response( + {"error": "Receiver email is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_USE_SSL, + EMAIL_FROM, + ) = get_email_configuration() + + # Configure all the connections + connection = get_connection( + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=EMAIL_USE_TLS == "1", + use_ssl=EMAIL_USE_SSL == "1", + ) + # Prepare email details + subject = "Email Notification from Plane" + message = ( + "This is a sample email notification sent from Plane application." + ) + # Send the email + try: + msg = EmailMultiAlternatives( + subject=subject, + body=message, + from_email=EMAIL_FROM, + to=[receiver_email], + connection=connection, + ) + msg.send(fail_silently=False) + return Response( + {"message": "Email successfully sent."}, + status=status.HTTP_200_OK, + ) + except BadHeaderError: + return Response( + {"error": "Invalid email header."}, + status=status.HTTP_400_BAD_REQUEST, + ) + except SMTPAuthenticationError: + return Response( + {"error": "Invalid credentials provided"}, + status=status.HTTP_400_BAD_REQUEST, + ) + except SMTPConnectError: + return Response( + {"error": "Could not connect with the SMTP server."}, + status=status.HTTP_400_BAD_REQUEST, + ) + except SMTPSenderRefused: + return Response( + {"error": "From address is invalid."}, + status=status.HTTP_400_BAD_REQUEST, + ) + except SMTPServerDisconnected: + return Response( + {"error": "SMTP server disconnected unexpectedly."}, + status=status.HTTP_400_BAD_REQUEST, + ) + except SMTPRecipientsRefused: + return Response( + {"error": "All recipient addresses were refused."}, + status=status.HTTP_400_BAD_REQUEST, + ) + except TimeoutError: + return Response( + { + "error": "Timeout error while trying to connect to the SMTP server." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + except ConnectionError: + return Response( + { + "error": "Network connection error. Please check your internet connection." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + except Exception: + return Response( + { + "error": "Could not send email. Please check your configuration" + }, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apiserver/plane/license/api/views/instance.py b/apiserver/plane/license/api/views/instance.py index 627904a16..2247bbeb1 100644 --- a/apiserver/plane/license/api/views/instance.py +++ b/apiserver/plane/license/api/views/instance.py @@ -1,33 +1,29 @@ # Python imports -import uuid +import os # Django imports -from django.utils import timezone -from django.contrib.auth.hashers import make_password -from django.core.validators import validate_email -from django.core.exceptions import ValidationError # Third party imports from rest_framework import status -from rest_framework.response import Response from rest_framework.permissions import AllowAny -from rest_framework_simplejwt.tokens import RefreshToken +from rest_framework.response import Response # Module imports from plane.app.views import BaseAPIView -from plane.license.models import Instance, InstanceAdmin, InstanceConfiguration -from plane.license.api.serializers import ( - InstanceSerializer, - InstanceAdminSerializer, - InstanceConfigurationSerializer, -) +from plane.db.models import Workspace from plane.license.api.permissions import ( InstanceAdminPermission, ) -from plane.db.models import User -from plane.license.utils.encryption import encrypt_data +from plane.license.api.serializers import ( + InstanceSerializer, +) +from plane.license.models import Instance +from plane.license.utils.instance_value import ( + get_configuration_value, +) from plane.utils.cache import cache_response, invalidate_cache + class InstanceEndpoint(BaseAPIView): def get_permissions(self): if self.request.method == "PATCH": @@ -51,7 +47,115 @@ class InstanceEndpoint(BaseAPIView): serializer = InstanceSerializer(instance) data = serializer.data data["is_activated"] = True - return Response(data, status=status.HTTP_200_OK) + # Get all the configuration + ( + IS_GOOGLE_ENABLED, + IS_GITHUB_ENABLED, + GITHUB_APP_NAME, + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + ENABLE_MAGIC_LINK_LOGIN, + ENABLE_EMAIL_PASSWORD, + SLACK_CLIENT_ID, + POSTHOG_API_KEY, + POSTHOG_HOST, + UNSPLASH_ACCESS_KEY, + OPENAI_API_KEY, + ) = get_configuration_value( + [ + { + "key": "IS_GOOGLE_ENABLED", + "default": os.environ.get("IS_GOOGLE_ENABLED", "0"), + }, + { + "key": "IS_GITHUB_ENABLED", + "default": os.environ.get("IS_GITHUB_ENABLED", "0"), + }, + { + "key": "GITHUB_APP_NAME", + "default": os.environ.get("GITHUB_APP_NAME", ""), + }, + { + "key": "EMAIL_HOST", + "default": os.environ.get("EMAIL_HOST", ""), + }, + { + "key": "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"), + }, + { + "key": "ENABLE_EMAIL_PASSWORD", + "default": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"), + }, + { + "key": "SLACK_CLIENT_ID", + "default": os.environ.get("SLACK_CLIENT_ID", None), + }, + { + "key": "POSTHOG_API_KEY", + "default": os.environ.get("POSTHOG_API_KEY", None), + }, + { + "key": "POSTHOG_HOST", + "default": os.environ.get("POSTHOG_HOST", None), + }, + { + "key": "UNSPLASH_ACCESS_KEY", + "default": os.environ.get("UNSPLASH_ACCESS_KEY", ""), + }, + { + "key": "OPENAI_API_KEY", + "default": os.environ.get("OPENAI_API_KEY", ""), + }, + ] + ) + + data = {} + # Authentication + data["is_google_enabled"] = IS_GOOGLE_ENABLED == "1" + data["is_github_enabled"] = IS_GITHUB_ENABLED == "1" + data["is_magic_login_enabled"] = ENABLE_MAGIC_LINK_LOGIN == "1" + data["is_email_password_enabled"] = ENABLE_EMAIL_PASSWORD == "1" + + # Github app name + data["github_app_name"] = str(GITHUB_APP_NAME) + + # Slack client + data["slack_client_id"] = SLACK_CLIENT_ID + + # Posthog + data["posthog_api_key"] = POSTHOG_API_KEY + data["posthog_host"] = POSTHOG_HOST + + # Unsplash + data["has_unsplash_configured"] = bool(UNSPLASH_ACCESS_KEY) + + # Open AI settings + data["has_openai_configured"] = bool(OPENAI_API_KEY) + + # File size settings + data["file_size_limit"] = float( + os.environ.get("FILE_SIZE_LIMIT", 5242880) + ) + + # is smtp configured + data["is_smtp_configured"] = ( + bool(EMAIL_HOST) + ) + instance_data = serializer.data + instance_data["workspaces_exist"] = Workspace.objects.count() > 1 + + response_data = {"config": data, "instance": instance_data} + return Response(response_data, status=status.HTTP_200_OK) @invalidate_cache(path="/api/instances/", user=False) def patch(self, request): @@ -66,196 +170,6 @@ class InstanceEndpoint(BaseAPIView): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) -class InstanceAdminEndpoint(BaseAPIView): - permission_classes = [ - InstanceAdminPermission, - ] - - @invalidate_cache(path="/api/instances/", user=False) - # Create an instance admin - def post(self, request): - email = request.data.get("email", False) - role = request.data.get("role", 20) - - if not email: - return Response( - {"error": "Email is required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - instance = Instance.objects.first() - if instance is None: - return Response( - {"error": "Instance is not registered yet"}, - status=status.HTTP_403_FORBIDDEN, - ) - - # Fetch the user - user = User.objects.get(email=email) - - instance_admin = InstanceAdmin.objects.create( - instance=instance, - user=user, - role=role, - ) - serializer = InstanceAdminSerializer(instance_admin) - return Response(serializer.data, status=status.HTTP_201_CREATED) - - @cache_response(60 * 60 * 2) - def get(self, request): - instance = Instance.objects.first() - if instance is None: - return Response( - {"error": "Instance is not registered yet"}, - status=status.HTTP_403_FORBIDDEN, - ) - instance_admins = InstanceAdmin.objects.filter(instance=instance) - serializer = InstanceAdminSerializer(instance_admins, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - - @invalidate_cache(path="/api/instances/", user=False) - def delete(self, request, pk): - instance = Instance.objects.first() - InstanceAdmin.objects.filter(instance=instance, pk=pk).delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class InstanceConfigurationEndpoint(BaseAPIView): - permission_classes = [ - InstanceAdminPermission, - ] - - @cache_response(60 * 60 * 2, user=False) - def get(self, request): - instance_configurations = InstanceConfiguration.objects.all() - serializer = InstanceConfigurationSerializer( - instance_configurations, many=True - ) - return Response(serializer.data, status=status.HTTP_200_OK) - - @invalidate_cache(path="/api/configs/", user=False) - @invalidate_cache(path="/api/mobile-configs/", user=False) - def patch(self, request): - configurations = InstanceConfiguration.objects.filter( - key__in=request.data.keys() - ) - - bulk_configurations = [] - for configuration in configurations: - value = request.data.get(configuration.key, configuration.value) - if configuration.is_encrypted: - configuration.value = encrypt_data(value) - else: - configuration.value = value - bulk_configurations.append(configuration) - - InstanceConfiguration.objects.bulk_update( - bulk_configurations, ["value"], batch_size=100 - ) - - serializer = InstanceConfigurationSerializer(configurations, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - - -def get_tokens_for_user(user): - refresh = RefreshToken.for_user(user) - return ( - str(refresh.access_token), - str(refresh), - ) - - -class InstanceAdminSignInEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] - - @invalidate_cache(path="/api/instances/", user=False) - def post(self, request): - # Check instance first - instance = Instance.objects.first() - if instance is None: - return Response( - {"error": "Instance is not configured"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # check if the instance is already activated - if InstanceAdmin.objects.first(): - return Response( - {"error": "Admin for this instance is already registered"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Get the email and password from all the user - email = request.data.get("email", False) - password = request.data.get("password", False) - - # return error if the email and password is not present - if not email or not password: - return Response( - {"error": "Email and password are required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Validate the email - email = email.strip().lower() - try: - validate_email(email) - except ValidationError: - return Response( - {"error": "Please provide a valid email address."}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Check if already a user exists or not - user = User.objects.filter(email=email).first() - - # Existing user - if user: - # Check user password - if not user.check_password(password): - return Response( - { - "error": "Sorry, we could not find a user with the provided credentials. Please try again." - }, - status=status.HTTP_403_FORBIDDEN, - ) - else: - user = User.objects.create( - email=email, - username=uuid.uuid4().hex, - password=make_password(password), - is_password_autoset=False, - ) - - # settings last active for the user - user.is_active = True - user.last_active = timezone.now() - user.last_login_time = timezone.now() - user.last_login_ip = request.META.get("REMOTE_ADDR") - user.last_login_uagent = request.META.get("HTTP_USER_AGENT") - user.token_updated_at = timezone.now() - user.save() - - # Register the user as an instance admin - _ = InstanceAdmin.objects.create( - user=user, - instance=instance, - ) - # Make the setup flag True - instance.is_setup_done = True - instance.save() - - # get tokens for user - access_token, refresh_token = get_tokens_for_user(user) - data = { - "access_token": access_token, - "refresh_token": refresh_token, - } - return Response(data, status=status.HTTP_200_OK) - - class SignUpScreenVisitedEndpoint(BaseAPIView): permission_classes = [ AllowAny, diff --git a/apiserver/plane/license/management/commands/configure_instance.py b/apiserver/plane/license/management/commands/configure_instance.py index 1bb103113..5a6eadc2e 100644 --- a/apiserver/plane/license/management/commands/configure_instance.py +++ b/apiserver/plane/license/management/commands/configure_instance.py @@ -13,6 +13,7 @@ class Command(BaseCommand): def handle(self, *args, **options): from plane.license.utils.encryption import encrypt_data + from plane.license.utils.instance_value import get_configuration_value config_keys = [ # Authentication Settings @@ -40,6 +41,12 @@ class Command(BaseCommand): "category": "GOOGLE", "is_encrypted": False, }, + { + "key": "GOOGLE_CLIENT_SECRET", + "value": os.environ.get("GOOGLE_CLIENT_SECRET"), + "category": "GOOGLE", + "is_encrypted": True, + }, { "key": "GITHUB_CLIENT_ID", "value": os.environ.get("GITHUB_CLIENT_ID"), @@ -137,3 +144,80 @@ class Command(BaseCommand): f"{obj.key} configuration already exists" ) ) + + keys = ["IS_GOOGLE_ENABLED", "IS_GITHUB_ENABLED"] + if not InstanceConfiguration.objects.filter(key__in=keys).exists(): + for key in keys: + if key == "IS_GOOGLE_ENABLED": + GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET = ( + get_configuration_value( + [ + { + "key": "GOOGLE_CLIENT_ID", + "default": os.environ.get( + "GOOGLE_CLIENT_ID", "" + ), + }, + { + "key": "GOOGLE_CLIENT_SECRET", + "default": os.environ.get( + "GOOGLE_CLIENT_SECRET", "0" + ), + }, + ] + ) + ) + if bool(GOOGLE_CLIENT_ID) and bool(GOOGLE_CLIENT_SECRET): + value = "1" + else: + value = "0" + InstanceConfiguration.objects.create( + key=key, + value=value, + category="AUTHENTICATION", + is_encrypted=False, + ) + self.stdout.write( + self.style.SUCCESS( + f"{key} loaded with value from environment variable." + ) + ) + if key == "IS_GITHUB_ENABLED": + GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET = ( + get_configuration_value( + [ + { + "key": "GITHUB_CLIENT_ID", + "default": os.environ.get( + "GITHUB_CLIENT_ID", "" + ), + }, + { + "key": "GITHUB_CLIENT_SECRET", + "default": os.environ.get( + "GITHUB_CLIENT_SECRET", "0" + ), + }, + ] + ) + ) + if bool(GITHUB_CLIENT_ID) and bool(GITHUB_CLIENT_SECRET): + value = "1" + else: + value = "0" + InstanceConfiguration.objects.create( + key="IS_GITHUB_ENABLED", + value=value, + category="AUTHENTICATION", + is_encrypted=False, + ) + self.stdout.write( + self.style.SUCCESS( + f"{key} loaded with value from environment variable." + ) + ) + else: + for key in keys: + self.stdout.write( + self.style.WARNING(f"{key} configuration already exists") + ) diff --git a/apiserver/plane/license/urls.py b/apiserver/plane/license/urls.py index e6315e021..b95ae74d6 100644 --- a/apiserver/plane/license/urls.py +++ b/apiserver/plane/license/urls.py @@ -1,11 +1,15 @@ from django.urls import path from plane.license.api.views import ( - InstanceEndpoint, + EmailCredentialCheckEndpoint, InstanceAdminEndpoint, - InstanceConfigurationEndpoint, InstanceAdminSignInEndpoint, + InstanceAdminSignUpEndpoint, + InstanceConfigurationEndpoint, + InstanceEndpoint, SignUpScreenVisitedEndpoint, + InstanceAdminUserMeEndpoint, + InstanceAdminSignOutEndpoint, ) urlpatterns = [ @@ -19,6 +23,16 @@ urlpatterns = [ InstanceAdminEndpoint.as_view(), name="instance-admins", ), + path( + "admins/me/", + InstanceAdminUserMeEndpoint.as_view(), + name="instance-admins", + ), + path( + "admins/sign-out/", + InstanceAdminSignOutEndpoint.as_view(), + name="instance-admins", + ), path( "admins//", InstanceAdminEndpoint.as_view(), @@ -34,9 +48,19 @@ urlpatterns = [ InstanceAdminSignInEndpoint.as_view(), name="instance-admin-sign-in", ), + path( + "admins/sign-up/", + InstanceAdminSignUpEndpoint.as_view(), + name="instance-admin-sign-in", + ), path( "admins/sign-up-screen-visited/", SignUpScreenVisitedEndpoint.as_view(), name="instance-sign-up", ), + path( + "email-credentials-check/", + EmailCredentialCheckEndpoint.as_view(), + name="email-credential-check", + ), ] diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 5a99f06ef..7ee1bdf55 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -3,7 +3,6 @@ # Python imports import os import ssl -from datetime import timedelta from urllib.parse import urlparse import certifi @@ -45,10 +44,9 @@ INSTALLED_APPS = [ "plane.middleware", "plane.license", "plane.api", + "plane.authentication", # Third-party things "rest_framework", - "rest_framework.authtoken", - "rest_framework_simplejwt.token_blacklist", "corsheaders", "django_celery_beat", "storages", @@ -58,7 +56,7 @@ INSTALLED_APPS = [ MIDDLEWARE = [ "corsheaders.middleware.CorsMiddleware", "django.middleware.security.SecurityMiddleware", - "django.contrib.sessions.middleware.SessionMiddleware", + "plane.authentication.middleware.session.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", @@ -71,7 +69,7 @@ MIDDLEWARE = [ # Rest Framework settings REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": ( - "rest_framework_simplejwt.authentication.JWTAuthentication", + "rest_framework.authentication.SessionAuthentication", ), "DEFAULT_PERMISSION_CLASSES": ( "rest_framework.permissions.IsAuthenticated", @@ -80,6 +78,7 @@ REST_FRAMEWORK = { "DEFAULT_FILTER_BACKENDS": ( "django_filters.rest_framework.DjangoFilterBackend", ), + "EXCEPTION_HANDLER": "plane.authentication.adapter.exception.auth_exception_handler", } # Django Auth Backend @@ -109,9 +108,6 @@ TEMPLATES = [ }, ] -# Cookie Settings -SESSION_COOKIE_SECURE = True -CSRF_COOKIE_SECURE = True # CORS Settings CORS_ALLOW_CREDENTIALS = True @@ -122,8 +118,14 @@ cors_allowed_origins = [ ] if cors_allowed_origins: CORS_ALLOWED_ORIGINS = cors_allowed_origins + secure_origins = ( + False + if [origin for origin in cors_allowed_origins if "http:" in origin] + else True + ) else: CORS_ALLOW_ALL_ORIGINS = True + secure_origins = False # Application Settings WSGI_APPLICATION = "plane.wsgi.application" @@ -246,35 +248,6 @@ if AWS_S3_ENDPOINT_URL: AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:" -# JWT Auth Configuration -SIMPLE_JWT = { - "ACCESS_TOKEN_LIFETIME": timedelta(minutes=43200), - "REFRESH_TOKEN_LIFETIME": timedelta(days=43200), - "ROTATE_REFRESH_TOKENS": False, - "BLACKLIST_AFTER_ROTATION": False, - "UPDATE_LAST_LOGIN": False, - "ALGORITHM": "HS256", - "SIGNING_KEY": SECRET_KEY, - "VERIFYING_KEY": None, - "AUDIENCE": None, - "ISSUER": None, - "JWK_URL": None, - "LEEWAY": 0, - "AUTH_HEADER_TYPES": ("Bearer",), - "AUTH_HEADER_NAME": "HTTP_AUTHORIZATION", - "USER_ID_FIELD": "id", - "USER_ID_CLAIM": "user_id", - "USER_AUTHENTICATION_RULE": "rest_framework_simplejwt.authentication.default_user_authentication_rule", - "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",), - "TOKEN_TYPE_CLAIM": "token_type", - "TOKEN_USER_CLASS": "rest_framework_simplejwt.models.TokenUser", - "JTI_CLAIM": "jti", - "SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp", - "SLIDING_TOKEN_LIFETIME": timedelta(minutes=5), - "SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=1), -} - - # Celery Configuration CELERY_TIMEZONE = TIME_ZONE CELERY_TASK_SERIALIZER = "json" @@ -350,3 +323,22 @@ INSTANCE_KEY = os.environ.get( SKIP_ENV_VAR = os.environ.get("SKIP_ENV_VAR", "1") == "1" DATA_UPLOAD_MAX_MEMORY_SIZE = int(os.environ.get("FILE_SIZE_LIMIT", 5242880)) + +# Cookie Settings +SESSION_COOKIE_SECURE = secure_origins +SESSION_COOKIE_HTTPONLY = True +SESSION_ENGINE = "plane.db.models.session" +SESSION_COOKIE_AGE = 604800 +SESSION_COOKIE_NAME = "plane-session-id" +SESSION_COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", None) +SESSION_SAVE_EVERY_REQUEST = True + +# Admin Cookie +ADMIN_SESSION_COOKIE_NAME = "plane-admin-session-id" +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) diff --git a/apiserver/plane/settings/local.py b/apiserver/plane/settings/local.py index b00684eae..4f67e638b 100644 --- a/apiserver/plane/settings/local.py +++ b/apiserver/plane/settings/local.py @@ -35,7 +35,11 @@ CORS_ALLOWED_ORIGINS = [ "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 diff --git a/apiserver/plane/urls.py b/apiserver/plane/urls.py index 3b042ea1f..aac6459b3 100644 --- a/apiserver/plane/urls.py +++ b/apiserver/plane/urls.py @@ -2,10 +2,9 @@ """ -from django.urls import path, include, re_path -from django.views.generic import TemplateView - from django.conf import settings +from django.urls import include, path, re_path +from django.views.generic import TemplateView handler404 = "plane.app.views.error_404.custom_404_view" @@ -15,6 +14,7 @@ urlpatterns = [ path("api/public/", include("plane.space.urls")), path("api/instances/", include("plane.license.urls")), path("api/v1/", include("plane.api.urls")), + path("auth/", include("plane.authentication.urls")), path("", include("plane.web.urls")), ] diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index 2b7d383ba..05789af59 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 +jwt==1.3.1 \ No newline at end of file diff --git a/apiserver/requirements/local.txt b/apiserver/requirements/local.txt index 426236ed8..02792201b 100644 --- a/apiserver/requirements/local.txt +++ b/apiserver/requirements/local.txt @@ -1,3 +1,5 @@ -r base.txt - -django-debug-toolbar==4.1.0 \ No newline at end of file +# debug toolbar +django-debug-toolbar==4.3.0 +# formatter +ruff==0.4.2 \ No newline at end of file diff --git a/apiserver/requirements/production.txt b/apiserver/requirements/production.txt index bea44fcfe..ed763c0df 100644 --- a/apiserver/requirements/production.txt +++ b/apiserver/requirements/production.txt @@ -1,3 +1,3 @@ -r base.txt - +# server gunicorn==22.0.0 diff --git a/apiserver/requirements/test.txt b/apiserver/requirements/test.txt index d3272191e..1ffc82d00 100644 --- a/apiserver/requirements/test.txt +++ b/apiserver/requirements/test.txt @@ -1,4 +1,4 @@ -r base.txt - +# test checker pytest==7.1.2 coverage==6.5.0 \ No newline at end of file diff --git a/deploy/selfhost/README.md b/deploy/selfhost/README.md index 8988e77f5..35d58f3d2 100644 --- a/deploy/selfhost/README.md +++ b/deploy/selfhost/README.md @@ -13,7 +13,7 @@ Let's get started!

Best way to start is to create EC2 maching on AWS. It must of minimum t3.medium/t3a/medium

Run the below command to install docker engine.

-`curl -fsSL https://get.docker.com -o install-docker.sh` +`curl -fsSL https://get.docker.com | sh -` @@ -94,15 +94,16 @@ Lets get started by running the `./setup.sh` command. This will prompt you with the below options. -``` +```bash Select a Action you want to perform: - 1) Install + 1) Install (arm64) 2) Start 3) Stop 4) Restart 5) Upgrade - 6) Exit - + 6) View Logs + 7) Backup Data + 8) Exit Action [2]: 1 ``` @@ -111,9 +112,9 @@ For the 1st time setup, type "1" as action input. This will create a create a folder `plane-app` or `plane-app-preview` (in case of preview deployment) and will download 2 files inside that - `docker-compose.yaml` -- `.env` +- `plane.env` -Again the `options [1-6]` will be popped up and this time hit `6` to exit. +Again the `options [1-7]` will be popped up and this time hit `7` to exit. --- @@ -136,14 +137,16 @@ There are many other settings you can play with, but we suggest you configure `E Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `2` to start the sevices -``` +```bash Select a Action you want to perform: - 1) Install + 1) Install (x86_64) 2) Start 3) Stop 4) Restart 5) Upgrade - 6) Exit + 6) View Logs + 7) Backup Data + 8) Exit Action [2]: 2 ``` @@ -167,14 +170,16 @@ In case you want to make changes to `.env` variables, we suggest you to stop the Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `3` to stop the sevices -``` +```bash Select a Action you want to perform: - 1) Install + 1) Install (x86_64) 2) Start 3) Stop 4) Restart 5) Upgrade - 6) Exit + 6) View Logs + 7) Backup Data + 8) Exit Action [2]: 3 ``` @@ -191,14 +196,16 @@ In case you want to make changes to `.env` variables, without stopping the serve Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `4` to restart the sevices -``` +```bash Select a Action you want to perform: - 1) Install + 1) Install (x86_64) 2) Start 3) Stop 4) Restart 5) Upgrade - 6) Exit + 6) View Logs + 7) Backup Data + 8) Exit Action [2]: 4 ``` @@ -215,14 +222,16 @@ It is always advised to keep Plane up to date with the latest release. Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `5` to upgrade the release. -``` +```bash Select a Action you want to perform: - 1) Install + 1) Install (x86_64) 2) Start 3) Stop 4) Restart 5) Upgrade - 6) Exit + 6) View Logs + 7) Backup Data + 8) Exit Action [2]: 5 ``` @@ -233,11 +242,136 @@ You must expect the below message ![Alt text](images/upgrade.png) -Once done, choose `6` to exit from prompt. +Once done, choose `8` to exit from prompt. > It is very important for you to compare the 2 files `variables-upgrade.env` and `.env`. Copy the newly added variable from downloaded file to `.env` and set the expected values. -Once done with making changes in `.env` file, jump on to `Start Server` +Once done with making changes in `plane.env` file, jump on to `Start Server` + +--- + +### View Logs + +There would a time when you might want to check what is happening inside the API, Worker or any other container. + +Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `6` to view logs. + +```bash +Select a Action you want to perform: + 1) Install (x86_64) + 2) Start + 3) Stop + 4) Restart + 5) Upgrade + 6) View Logs + 7) Backup Data + 8) Exit + +Action [2]: 6 +``` + + +This will further open sub-menu with list of services +```bash +Select a Service you want to view the logs for: + 1) Web + 2) Space + 3) API + 4) Worker + 5) Beat-Worker + 6) Migrator + 7) Proxy + 8) Redis + 9) Postgres + 10) Minio + 0) Back to Main Menu + +Service: +``` + +Select any of the service to view the logs e.g. `3`. Expect something similar to this +```bash +api-1 | Waiting for database... +api-1 | Database available! +api-1 | Waiting for database migrations to complete... +api-1 | Waiting for database migrations to complete... +api-1 | Waiting for database migrations to complete... +api-1 | Waiting for database migrations to complete... +api-1 | Waiting for database migrations to complete... +api-1 | Waiting for database migrations to complete... +api-1 | Waiting for database migrations to complete... +api-1 | No migrations Pending. Starting processes ... +api-1 | Instance registered +api-1 | ENABLE_SIGNUP loaded with value from environment variable. +api-1 | ENABLE_EMAIL_PASSWORD loaded with value from environment variable. +api-1 | ENABLE_MAGIC_LINK_LOGIN loaded with value from environment variable. +api-1 | GOOGLE_CLIENT_ID loaded with value from environment variable. +api-1 | GITHUB_CLIENT_ID loaded with value from environment variable. +api-1 | GITHUB_CLIENT_SECRET loaded with value from environment variable. +api-1 | EMAIL_HOST loaded with value from environment variable. +api-1 | EMAIL_HOST_USER loaded with value from environment variable. +api-1 | EMAIL_HOST_PASSWORD loaded with value from environment variable. +api-1 | EMAIL_PORT loaded with value from environment variable. +api-1 | EMAIL_FROM loaded with value from environment variable. +api-1 | EMAIL_USE_TLS loaded with value from environment variable. +api-1 | EMAIL_USE_SSL loaded with value from environment variable. +api-1 | OPENAI_API_KEY loaded with value from environment variable. +api-1 | GPT_ENGINE loaded with value from environment variable. +api-1 | UNSPLASH_ACCESS_KEY loaded with value from environment variable. +api-1 | Checking bucket... +api-1 | Bucket 'uploads' does not exist. Creating bucket... +api-1 | Bucket 'uploads' created successfully. +api-1 | Public read access policy set for bucket 'uploads'. +api-1 | Cache Cleared +api-1 | [2024-05-02 03:56:01 +0000] [1] [INFO] Starting gunicorn 21.2.0 +api-1 | [2024-05-02 03:56:01 +0000] [1] [INFO] Listening at: http://0.0.0.0:8000 (1) +api-1 | [2024-05-02 03:56:01 +0000] [1] [INFO] Using worker: uvicorn.workers.UvicornWorker +api-1 | [2024-05-02 03:56:01 +0000] [25] [INFO] Booting worker with pid: 25 +api-1 | [2024-05-02 03:56:03 +0000] [25] [INFO] Started server process [25] +api-1 | [2024-05-02 03:56:03 +0000] [25] [INFO] Waiting for application startup. +api-1 | [2024-05-02 03:56:03 +0000] [25] [INFO] ASGI 'lifespan' protocol appears unsupported. +api-1 | [2024-05-02 03:56:03 +0000] [25] [INFO] Application startup complete. + +``` + +To exit this, use `CTRL+C` and then you will land on to the main-menu with the list of actions. + +Similarly, you can view the logs of other services. + +--- + +### Backup Data + +There would a time when you might want to backup your data from docker volumes to external storage like S3 or drives. + +Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `8` to view logs. + +```bash +Select a Action you want to perform: + 1) Install (x86_64) + 2) Start + 3) Stop + 4) Restart + 5) Upgrade + 6) View Logs + 7) Backup Data + 8) Exit + +Action [2]: 7 +``` + +In response, you can find the backup folder + +```bash +Backing Up plane-app_pgdata +Backing Up plane-app_redisdata +Backing Up plane-app_uploads + +Backup completed successfully. Backup files are stored in /....../plane-app/backup/20240502-1120 +``` + +--- + ## Upgrading from v0.13.2 to v0.14.x diff --git a/deploy/selfhost/build.yml b/deploy/selfhost/build.yml index 92533a73b..e81701f5d 100644 --- a/deploy/selfhost/build.yml +++ b/deploy/selfhost/build.yml @@ -13,6 +13,12 @@ services: context: ./ dockerfile: ./space/Dockerfile.space + admin: + image: ${DOCKERHUB_USER:-local}/plane-admin:${APP_RELEASE:-latest} + build: + context: ./ + dockerfile: ./admin/Dockerfile.admin + api: image: ${DOCKERHUB_USER:-local}/plane-backend:${APP_RELEASE:-latest} build: diff --git a/deploy/selfhost/docker-compose.yml b/deploy/selfhost/docker-compose.yml index e62390987..6f12abd61 100644 --- a/deploy/selfhost/docker-compose.yml +++ b/deploy/selfhost/docker-compose.yml @@ -59,6 +59,18 @@ services: - api - worker - web + + admin: + <<: *app-env + image: ${DOCKERHUB_USER:-makeplane}/plane-space:${APP_RELEASE:-stable} + pull_policy: ${PULL_POLICY:-always} + restart: unless-stopped + command: node admin/server.js admin + deploy: + replicas: ${ADMIN_REPLICAS:-1} + depends_on: + - api + - web api: <<: *app-env diff --git a/deploy/selfhost/install.sh b/deploy/selfhost/install.sh index fdedf370e..aaf129524 100755 --- a/deploy/selfhost/install.sh +++ b/deploy/selfhost/install.sh @@ -69,7 +69,7 @@ function buildLocalImage() { export APP_RELEASE=stable fi - docker compose -f build.yml build --no-cache >&2 + /bin/bash -c "$COMPOSE_CMD -f build.yml build --no-cache" >&2 # cd $CURR_DIR # rm -rf $PLANE_TEMP_CODE_DIR echo "build_completed" @@ -127,7 +127,7 @@ function download() { exit 0 fi else - docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH pull + /bin/bash -c "$COMPOSE_CMD -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH pull" fi echo "" @@ -138,7 +138,7 @@ function download() { } function startServices() { - docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH up -d --quiet-pull + /bin/bash -c "$COMPOSE_CMD -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH up -d --quiet-pull" local migrator_container_id=$(docker container ls -aq -f "name=plane-app-migrator") if [ -n "$migrator_container_id" ]; then @@ -188,10 +188,9 @@ function startServices() { } function stopServices() { - docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH down + /bin/bash -c "$COMPOSE_CMD -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH down" } function restartServices() { - # docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH restart stopServices startServices } @@ -208,13 +207,13 @@ function upgrade() { function viewSpecificLogs(){ local SERVICE_NAME=$1 - if docker-compose -f $DOCKER_FILE_PATH ps | grep -q "$SERVICE_NAME"; then + if /bin/bash -c "$COMPOSE_CMD -f $DOCKER_FILE_PATH ps | grep -q '$SERVICE_NAME'"; then echo "Service '$SERVICE_NAME' is running." else echo "Service '$SERVICE_NAME' is not running." fi - docker compose -f $DOCKER_FILE_PATH logs -f $SERVICE_NAME + /bin/bash -c "$COMPOSE_CMD -f $DOCKER_FILE_PATH logs -f $SERVICE_NAME" } function viewLogs(){ @@ -282,6 +281,39 @@ function viewLogs(){ echo "INVALID SERVICE NAME SUPPLIED" fi } + +function backupSingleVolume() { + backupFolder=$1 + selectedVolume=$2 + # Backup data from Docker volume to the backup folder + docker run --rm -v "$selectedVolume":/source -v "$backupFolder":/backup busybox sh -c 'cp -r /source/* /backup/' +} + +function backupData() { + local datetime=$(date +"%Y%m%d-%H%M") + local BACKUP_FOLDER=$PLANE_INSTALL_DIR/backup/$datetime + mkdir -p "$BACKUP_FOLDER" + + volumes=$(docker volume ls -f "name=plane-app" --format "{{.Name}}" | grep -E "_pgdata|_redisdata|_uploads") + # Check if there are any matching volumes + if [ -z "$volumes" ]; then + echo "No volumes found starting with 'plane-app'" + exit 1 + fi + + for vol in $volumes; do + # selected_volume=$(echo "$volumes" | sed -n "${volume_number}p") + local backup_folder="$BACKUP_FOLDER/$vol" + mkdir -p "$backup_folder" + echo "Backing Up $vol" + backupSingleVolume "$backup_folder" "$vol" + done + + echo "" + echo "Backup completed successfully. Backup files are stored in $BACKUP_FOLDER" + echo "" + +} function askForAction() { local DEFAULT_ACTION=$1 @@ -295,10 +327,11 @@ function askForAction() { echo " 4) Restart" echo " 5) Upgrade" echo " 6) View Logs" - echo " 7) Exit" + echo " 7) Backup Data" + echo " 8) Exit" echo read -p "Action [2]: " ACTION - until [[ -z "$ACTION" || "$ACTION" =~ ^[1-7]$ ]]; do + until [[ -z "$ACTION" || "$ACTION" =~ ^[1-8]$ ]]; do echo "$ACTION: invalid selection." read -p "Action [2]: " ACTION done @@ -334,7 +367,11 @@ function askForAction() { then viewLogs $@ askForAction - elif [ "$ACTION" == "7" ] + elif [ "$ACTION" == "7" ] || [ "$DEFAULT_ACTION" == "backup" ] + then + backupData + askForAction + elif [ "$ACTION" == "8" ] then exit 0 else @@ -342,6 +379,14 @@ function askForAction() { fi } +# if docker-compose is installed +if command -v docker-compose &> /dev/null +then + COMPOSE_CMD="docker-compose" +else + COMPOSE_CMD="docker compose" +fi + # CPU ARCHITECHTURE BASED SETTINGS CPU_ARCH=$(uname -m) if [[ $CPU_ARCH == "amd64" || $CPU_ARCH == "x86_64" || ( $BRANCH == "master" && ( $CPU_ARCH == "arm64" || $CPU_ARCH == "aarch64" ) ) ]]; diff --git a/deploy/selfhost/variables.env b/deploy/selfhost/variables.env index e37350cf4..91e206bb4 100644 --- a/deploy/selfhost/variables.env +++ b/deploy/selfhost/variables.env @@ -2,6 +2,7 @@ APP_RELEASE=stable WEB_REPLICAS=1 SPACE_REPLICAS=1 +ADMIN_REPLICAS=1 API_REPLICAS=1 NGINX_PORT=80 diff --git a/docker-compose.yml b/docker-compose.yml index cf5a8421d..bf8066055 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,19 @@ services: command: /usr/local/bin/start.sh web/server.js web depends_on: - api - - worker + + admin: + container_name: admin + build: + context: . + dockerfile: ./admin/Dockerfile.admin + args: + DOCKER_BUILDKIT: 1 + restart: always + command: node admin/server.js admin + depends_on: + - api + - web space: container_name: space @@ -23,7 +35,6 @@ services: command: /usr/local/bin/start.sh space/server.js space depends_on: - api - - worker - web api: @@ -73,6 +84,23 @@ services: - plane-db - plane-redis + migrator: + container_name: plane-migrator + build: + context: ./apiserver + dockerfile: Dockerfile.api + args: + DOCKER_BUILDKIT: 1 + restart: no + command: > + sh -c "python manage.py wait_for_db && + python manage.py migrate" + env_file: + - ./apiserver/.env + depends_on: + - plane-db + - plane-redis + plane-db: container_name: plane-db image: postgres:15.2-alpine @@ -83,9 +111,9 @@ services: env_file: - .env environment: - POSTGRES_USER: ${PGUSER} - POSTGRES_DB: ${PGDATABASE} - POSTGRES_PASSWORD: ${PGPASSWORD} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} PGDATA: /var/lib/postgresql/data plane-redis: @@ -106,6 +134,7 @@ services: MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID} MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY} + # Comment this if you already have a reverse proxy running proxy: container_name: proxy @@ -122,6 +151,7 @@ services: - web - api - space + - admin volumes: pgdata: diff --git a/nginx/.prettierignore b/nginx/.prettierignore new file mode 100644 index 000000000..6aea6684d --- /dev/null +++ b/nginx/.prettierignore @@ -0,0 +1 @@ +nginx.conf.template \ No newline at end of file diff --git a/nginx/nginx.conf.dev b/nginx/nginx.conf.dev index f86c84aa8..872ff6748 100644 --- a/nginx/nginx.conf.dev +++ b/nginx/nginx.conf.dev @@ -23,10 +23,18 @@ http { proxy_set_header Connection "upgrade"; } + location /god-mode { + proxy_pass http://godmode:3000/; + } + location /api/ { proxy_pass http://api:8000/api/; } + location /auth/ { + proxy_pass http://api:8000/auth/; + } + location /spaces/ { rewrite ^/spaces/?$ /spaces/login break; proxy_pass http://space:4000/spaces/; diff --git a/nginx/nginx.conf.template b/nginx/nginx.conf.template index 780093b3b..829c32eed 100644 --- a/nginx/nginx.conf.template +++ b/nginx/nginx.conf.template @@ -25,12 +25,19 @@ http { } location /spaces/ { - rewrite ^/spaces/?$ /spaces/login break; proxy_pass http://space:3000/spaces/; } + + location /god-mode/ { + proxy_pass http://admin:3000/god-mode/; + } + + location /auth/ { + proxy_pass http://api:8000/auth/; + } location /${BUCKET_NAME}/ { proxy_pass http://plane-minio:9000/${BUCKET_NAME}/; } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index 39c8bfdc6..0c846ea0d 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "workspaces": [ "web", "space", + "admin", "packages/editor/*", "packages/eslint-config-custom", "packages/tailwind-config-custom", @@ -31,7 +32,7 @@ "turbo": "^1.13.2" }, "resolutions": { - "@types/react": "18.2.42" + "@types/react": "18.2.48" }, "packageManager": "yarn@1.22.19" } diff --git a/packages/tailwind-config-custom/tailwind.config.js b/packages/tailwind-config-custom/tailwind.config.js index 5d767e84f..2000f3abf 100644 --- a/packages/tailwind-config-custom/tailwind.config.js +++ b/packages/tailwind-config-custom/tailwind.config.js @@ -10,6 +10,7 @@ module.exports = { "./constants/**/*.{js,ts,jsx,tsx}", "./layouts/**/*.tsx", "./pages/**/*.tsx", + "./app/**/*.tsx", "./ui/**/*.tsx", "../packages/ui/**/*.{js,ts,jsx,tsx}", "../packages/editor/**/src/**/*.{js,ts,jsx,tsx}", diff --git a/packages/types/src/app.d.ts b/packages/types/src/app.d.ts deleted file mode 100644 index 06a433ddd..000000000 --- a/packages/types/src/app.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -export interface IAppConfig { - email_password_login: boolean; - file_size_limit: number; - github_app_name: string | null; - github_client_id: string | null; - google_client_id: string | null; - has_openai_configured: boolean; - has_unsplash_configured: boolean; - is_smtp_configured: boolean; - magic_login: boolean; - posthog_api_key: string | null; - posthog_host: string | null; - slack_client_id: string | null; -} diff --git a/packages/types/src/auth.d.ts b/packages/types/src/auth.d.ts index b20116c90..068062fc7 100644 --- a/packages/types/src/auth.d.ts +++ b/packages/types/src/auth.d.ts @@ -6,7 +6,8 @@ export interface IEmailCheckData { export interface IEmailCheckResponse { is_password_autoset: boolean; - is_existing: boolean; + status: boolean; + existing: boolean; } export interface ILoginTokenResponse { @@ -24,3 +25,7 @@ export interface IPasswordSignInData { email: string; password: string; } + +export interface ICsrfTokenData { + csrf_token: string; +} diff --git a/packages/types/src/current-user/accounts.d.ts b/packages/types/src/current-user/accounts.d.ts new file mode 100644 index 000000000..6c5146a7a --- /dev/null +++ b/packages/types/src/current-user/accounts.d.ts @@ -0,0 +1,17 @@ +export type TCurrentUserAccount = { + id: string | undefined; + + user: string | undefined; + + provider_account_id: string | undefined; + provider: "google" | "github" | string | undefined; + access_token: string | undefined; + access_token_expired_at: Date | undefined; + refresh_token: string | undefined; + refresh_token_expired_at: Date | undefined; + last_connected_at: Date | undefined; + metadata: object | undefined; + + created_at: Date | undefined; + updated_at: Date | undefined; +}; diff --git a/packages/types/src/current-user/index.ts b/packages/types/src/current-user/index.ts new file mode 100644 index 000000000..43a43b9cd --- /dev/null +++ b/packages/types/src/current-user/index.ts @@ -0,0 +1,3 @@ +export * from "./user"; +export * from "./profile"; +export * from "./accounts"; diff --git a/packages/types/src/current-user/profile.d.ts b/packages/types/src/current-user/profile.d.ts new file mode 100644 index 000000000..00ed5c0b5 --- /dev/null +++ b/packages/types/src/current-user/profile.d.ts @@ -0,0 +1,29 @@ +export type TUserProfile = { + id: string | undefined; + + user: string | undefined; + role: string | undefined; + last_workspace_id: string | undefined; + + theme: { + theme: string | undefined; + }; + + onboarding_step: { + workspace_join: boolean; + profile_complete: boolean; + workspace_create: boolean; + workspace_invite: boolean; + }; + is_onboarded: boolean; + is_tour_completed: boolean; + + use_case: string | undefined; + + billing_address_country: string | undefined; + billing_address: string | undefined; + has_billing_address: boolean; + + created_at: Date | string; + updated_at: Date | string; +}; diff --git a/packages/types/src/current-user/user.d.ts b/packages/types/src/current-user/user.d.ts new file mode 100644 index 000000000..9bc67b6cf --- /dev/null +++ b/packages/types/src/current-user/user.d.ts @@ -0,0 +1,30 @@ +export type TCurrentUser = { + id: string | undefined; + avatar: string | undefined; + cover_image: string | undefined; + date_joined: Date | undefined; + display_name: string | undefined; + email: string | undefined; + first_name: string | undefined; + last_name: string | undefined; + is_active: boolean; + is_bot: boolean; + is_email_verified: boolean; + is_managed: boolean; + mobile_number: string | undefined; + user_timezone: string | undefined; + username: string | undefined; + is_password_autoset: boolean; +}; + +export type TCurrentUserSettings = { + id: string | undefined; + email: string | undefined; + workspace: { + last_workspace_id: string | undefined; + last_workspace_slug: string | undefined; + fallback_workspace_id: string | undefined; + fallback_workspace_slug: string | undefined; + invites: number | undefined; + }; +}; diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index 4d98b8f7a..b8dd2d3c1 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -14,17 +14,17 @@ export * from "./estimate"; export * from "./importer"; export * from "./inbox"; export * from "./analytics"; +export * from "./api_token"; +export * from "./app"; +export * from "./auth"; export * from "./calendar"; +export * from "./instance"; +export * from "./issues/base"; // TODO: Remove this after development and the refactor/mobx-store-issue branch is stable export * from "./notifications"; -export * from "./waitlist"; export * from "./reaction"; export * from "./view-props"; -export * from "./workspace-views"; +export * from "./waitlist"; export * from "./webhook"; -export * from "./issues/base"; // TODO: Remove this after development and the refactor/mobx-store-issue branch is stable -export * from "./auth"; -export * from "./api_token"; -export * from "./instance"; -export * from "./app"; +export * from "./workspace-views"; export * from "./common"; export * from "./pragmatic"; diff --git a/packages/types/src/instance.d.ts b/packages/types/src/instance.d.ts deleted file mode 100644 index e11b6add8..000000000 --- a/packages/types/src/instance.d.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { IUserLite } from "./users"; - -export interface IInstance { - id: string; - created_at: string; - updated_at: string; - instance_name: string; - whitelist_emails: string | null; - instance_id: string; - license_key: string | null; - api_key: string; - version: string; - last_checked_at: string; - namespace: string | null; - is_telemetry_enabled: boolean; - is_support_required: boolean; - created_by: string | null; - updated_by: string | null; - is_activated: boolean; - is_setup_done: boolean; -} - -export interface IInstanceConfiguration { - id: string; - created_at: string; - updated_at: string; - key: string; - value: string; - created_by: string | null; - updated_by: string | null; -} - -export interface IFormattedInstanceConfiguration { - [key: string]: string; -} - -export interface IInstanceAdmin { - created_at: string; - created_by: string; - id: string; - instance: string; - role: string; - updated_at: string; - updated_by: string; - user: string; - user_detail: IUserLite; -} diff --git a/packages/types/src/instance/ai.d.ts b/packages/types/src/instance/ai.d.ts new file mode 100644 index 000000000..0ac34557a --- /dev/null +++ b/packages/types/src/instance/ai.d.ts @@ -0,0 +1 @@ +export type TInstanceAIConfigurationKeys = "OPENAI_API_KEY" | "GPT_ENGINE"; diff --git a/packages/types/src/instance/auth.d.ts b/packages/types/src/instance/auth.d.ts new file mode 100644 index 000000000..0366ce660 --- /dev/null +++ b/packages/types/src/instance/auth.d.ts @@ -0,0 +1,22 @@ +export type TInstanceAuthenticationMethodKeys = + | "ENABLE_SIGNUP" + | "ENABLE_MAGIC_LINK_LOGIN" + | "ENABLE_EMAIL_PASSWORD" + | "IS_GOOGLE_ENABLED" + | "IS_GITHUB_ENABLED"; + +export type TInstanceGoogleAuthenticationConfigurationKeys = + | "GOOGLE_CLIENT_ID" + | "GOOGLE_CLIENT_SECRET"; + +export type TInstanceGithubAuthenticationConfigurationKeys = + | "GITHUB_CLIENT_ID" + | "GITHUB_CLIENT_SECRET"; + +type TInstanceAuthenticationConfigurationKeys = + | TInstanceGoogleAuthenticationConfigurationKeys + | TInstanceGithubAuthenticationConfigurationKeys; + +export type TInstanceAuthenticationKeys = + | TInstanceAuthenticationMethodKeys + | TInstanceAuthenticationConfigurationKeys; diff --git a/packages/types/src/instance/base.d.ts b/packages/types/src/instance/base.d.ts new file mode 100644 index 000000000..87f03c68f --- /dev/null +++ b/packages/types/src/instance/base.d.ts @@ -0,0 +1,79 @@ +import { IUserLite } from "../users"; +import { + TInstanceAIConfigurationKeys, + TInstanceEmailConfigurationKeys, + TInstanceImageConfigurationKeys, + TInstanceAuthenticationKeys, +} from "./"; + +export interface IInstance { + instance: { + id: string; + created_at: string; + updated_at: string; + instance_name: string | undefined; + whitelist_emails: string | undefined; + instance_id: string | undefined; + license_key: string | undefined; + api_key: string | undefined; + version: string | undefined; + last_checked_at: string | undefined; + namespace: string | undefined; + is_telemetry_enabled: boolean; + is_support_required: boolean; + is_activated: boolean; + is_setup_done: boolean; + is_signup_screen_visited: boolean; + user_count: number | undefined; + is_verified: boolean; + created_by: string | undefined; + updated_by: string | undefined; + workspaces_exist: boolean; + }; + config: { + is_google_enabled: boolean; + is_github_enabled: boolean; + is_magic_login_enabled: boolean; + is_email_password_enabled: boolean; + github_app_name: string | undefined; + slack_client_id: string | undefined; + posthog_api_key: string | undefined; + posthog_host: string | undefined; + has_unsplash_configured: boolean; + has_openai_configured: boolean; + file_size_limit: number | undefined; + is_smtp_configured: boolean; + }; +} + +export interface IInstanceAdmin { + created_at: string; + created_by: string; + id: string; + instance: string; + role: string; + updated_at: string; + updated_by: string; + user: string; + user_detail: IUserLite; +} + +export type TInstanceConfigurationKeys = + | TInstanceAIConfigurationKeys + | TInstanceEmailConfigurationKeys + | TInstanceImageConfigurationKeys + | TInstanceAuthenticationKeys; + +export interface IInstanceConfiguration { + id: string; + created_at: string; + updated_at: string; + key: TInstanceConfigurationKeys; + value: string; + created_by: string | null; + updated_by: string | null; +} + +export type IFormattedInstanceConfiguration = { + [key in TInstanceConfigurationKeys]: string; +}; diff --git a/packages/types/src/instance/email.d.ts b/packages/types/src/instance/email.d.ts new file mode 100644 index 000000000..d7fe8be3d --- /dev/null +++ b/packages/types/src/instance/email.d.ts @@ -0,0 +1,8 @@ +export type TInstanceEmailConfigurationKeys = + | "EMAIL_HOST" + | "EMAIL_PORT" + | "EMAIL_HOST_USER" + | "EMAIL_HOST_PASSWORD" + | "EMAIL_USE_TLS" + | "EMAIL_USE_SSL" + | "EMAIL_FROM"; diff --git a/packages/types/src/instance/image.d.ts b/packages/types/src/instance/image.d.ts new file mode 100644 index 000000000..7eee3bf91 --- /dev/null +++ b/packages/types/src/instance/image.d.ts @@ -0,0 +1 @@ +export type TInstanceImageConfigurationKeys = "UNSPLASH_ACCESS_KEY"; \ No newline at end of file diff --git a/packages/types/src/instance/index.d.ts b/packages/types/src/instance/index.d.ts new file mode 100644 index 000000000..c68f196d3 --- /dev/null +++ b/packages/types/src/instance/index.d.ts @@ -0,0 +1,5 @@ +export * from "./ai"; +export * from "./auth"; +export * from "./base"; +export * from "./email"; +export * from "./image"; diff --git a/packages/types/src/users.d.ts b/packages/types/src/users.d.ts index 5920f0b49..c191cac89 100644 --- a/packages/types/src/users.d.ts +++ b/packages/types/src/users.d.ts @@ -1,13 +1,15 @@ import { + EUserProjectRoles, IIssueActivity, TIssuePriorities, TStateGroups, - EUserProjectRoles, } from "."; +type TLoginMediums = "email" | "magic-code" | "github" | "google"; + export interface IUser { id: string; - avatar: string; + avatar: string | null; cover_image: string | null; date_joined: string; display_name: string; @@ -17,50 +19,75 @@ export interface IUser { is_active: boolean; is_bot: boolean; is_email_verified: boolean; - is_managed: boolean; - is_onboarded: boolean; is_password_autoset: boolean; is_tour_completed: boolean; mobile_number: string | null; role: string | null; - onboarding_step: { - workspace_join?: boolean; - profile_complete?: boolean; - workspace_create?: boolean; - workspace_invite?: boolean; - }; last_workspace_id: string; user_timezone: string; username: string; + last_login_medium: TLoginMediums; theme: IUserTheme; - use_case?: string; } +export interface IUserAccount { + provider_account_id: string; + provider: string; + created_at: Date; + updated_at: Date; +} + +export type TUserProfile = { + id: string | undefined; + user: string | undefined; + role: string | undefined; + last_workspace_id: string | undefined; + theme: { + text: string | undefined; + theme: string | undefined; + palette: string | undefined; + primary: string | undefined; + background: string | undefined; + darkPalette: boolean | undefined; + sidebarText: string | undefined; + sidebarBackground: string | undefined; + }; + onboarding_step: TOnboardingSteps; + is_onboarded: boolean; + is_tour_completed: boolean; + use_case: string | undefined; + billing_address_country: string | undefined; + billing_address: string | undefined; + has_billing_address: boolean; + created_at: Date | string; + updated_at: Date | string; +}; + export interface IInstanceAdminStatus { is_instance_admin: boolean; } export interface IUserSettings { - id: string; - email: string; + id: string | undefined; + email: string | undefined; workspace: { - last_workspace_id: string; - last_workspace_slug: string; - fallback_workspace_id: string; - fallback_workspace_slug: string; - invites: number; + last_workspace_id: string | undefined; + last_workspace_slug: string | undefined; + fallback_workspace_id: string | undefined; + fallback_workspace_slug: string | undefined; + invites: number | undefined; }; } export interface IUserTheme { - background: string; - text: string; - primary: string; - sidebarBackground: string; - sidebarText: string; - darkPalette: boolean; - palette: string; - theme: string; + text: string | undefined; + theme: string | undefined; + palette: string | undefined; + primary: string | undefined; + background: string | undefined; + darkPalette: boolean | undefined; + sidebarText: string | undefined; + sidebarBackground: string | undefined; } export interface IUserLite { diff --git a/packages/ui/src/button/helper.tsx b/packages/ui/src/button/helper.tsx index 13a4e8040..0378b0334 100644 --- a/packages/ui/src/button/helper.tsx +++ b/packages/ui/src/button/helper.tsx @@ -8,7 +8,8 @@ export type TButtonVariant = | "accent-danger" | "outline-danger" | "link-danger" - | "tertiary-danger"; + | "tertiary-danger" + | "link-neutral"; export type TButtonSizes = "sm" | "md" | "lg" | "xl"; @@ -97,6 +98,12 @@ export const buttonStyling: IButtonStyling = { pressed: `focus:text-red-400`, disabled: `cursor-not-allowed !text-red-300`, }, + "link-neutral": { + default: `text-custom-text-300`, + hover: `hover:text-custom-text-200`, + pressed: `focus:text-custom-text-100`, + disabled: `cursor-not-allowed !text-custom-text-400`, + }, }; export const getButtonStyling = (variant: TButtonVariant, size: TButtonSizes, disabled: boolean = false): string => { diff --git a/space/Dockerfile.space b/space/Dockerfile.space index 6e2bfd065..a10690642 100644 --- a/space/Dockerfile.space +++ b/space/Dockerfile.space @@ -48,7 +48,7 @@ ENV NEXT_PUBLIC_DEPLOY_WITH_NGINX=$NEXT_PUBLIC_DEPLOY_WITH_NGINX COPY start.sh /usr/local/bin/ RUN chmod +x /usr/local/bin/start.sh - ENV NEXT_TELEMETRY_DISABLED 1 +ENV TURBO_TELEMETRY_DISABLED 1 EXPOSE 3000 diff --git a/space/components/accounts/auth-forms/email.tsx b/space/components/accounts/auth-forms/email.tsx new file mode 100644 index 000000000..550dea2bf --- /dev/null +++ b/space/components/accounts/auth-forms/email.tsx @@ -0,0 +1,92 @@ +import React from "react"; +import { Controller, useForm } from "react-hook-form"; +// icons +import { XCircle, CircleAlert } from "lucide-react"; +// ui +import { Button, Input, Spinner } from "@plane/ui"; +// helpers +import { checkEmailValidity } from "@/helpers/string.helper"; +// types +import { IEmailCheckData } from "@/types/auth"; + +type Props = { + onSubmit: (data: IEmailCheckData) => Promise; +}; + +type TEmailFormValues = { + email: string; +}; + +export const EmailForm: React.FC = (props) => { + const { onSubmit } = props; + + const { + control, + formState: { errors, isSubmitting, isValid }, + handleSubmit, + } = useForm({ + defaultValues: { + email: "", + }, + mode: "onChange", + reValidateMode: "onChange", + }); + + const handleFormSubmit = async (data: TEmailFormValues) => { + const payload: IEmailCheckData = { + email: data.email, + }; + onSubmit(payload); + }; + + return ( +
+
+ + checkEmailValidity(value) || "Email is invalid", + }} + render={({ field: { value, onChange, ref } }) => ( + <> +
+ + {value.length > 0 && ( + onChange("")} + /> + )} +
+ {errors.email && ( +

+ + {errors.email.message} +

+ )} + + )} + /> +
+ +
+ ); +}; diff --git a/web/components/account/sign-in-forms/forgot-password-popover.tsx b/space/components/accounts/auth-forms/forgot-password-popover.tsx similarity index 100% rename from web/components/account/sign-in-forms/forgot-password-popover.tsx rename to space/components/accounts/auth-forms/forgot-password-popover.tsx diff --git a/web/components/account/sign-in-forms/index.ts b/space/components/accounts/auth-forms/index.ts similarity index 78% rename from web/components/account/sign-in-forms/index.ts rename to space/components/accounts/auth-forms/index.ts index 8e44f490b..68f0e3afd 100644 --- a/web/components/account/sign-in-forms/index.ts +++ b/space/components/accounts/auth-forms/index.ts @@ -1,6 +1,5 @@ export * from "./email"; -export * from "./forgot-password-popover"; -export * from "./optional-set-password"; export * from "./password"; export * from "./root"; export * from "./unique-code"; +export * from "./forgot-password-popover"; diff --git a/space/components/accounts/auth-forms/password.tsx b/space/components/accounts/auth-forms/password.tsx new file mode 100644 index 000000000..35d8703b6 --- /dev/null +++ b/space/components/accounts/auth-forms/password.tsx @@ -0,0 +1,216 @@ +import React, { useEffect, useMemo, useState } from "react"; +// icons +import Link from "next/link"; +import { useRouter } from "next/router"; +import { Eye, EyeOff, XCircle } from "lucide-react"; +// ui +import { Button, Input, Spinner } from "@plane/ui"; +import { EAuthModes, EAuthSteps, ForgotPasswordPopover, PasswordStrengthMeter } from "@/components/accounts"; +// helpers +import { API_BASE_URL } from "@/helpers/common.helper"; +// services +import { getPasswordStrength } from "@/helpers/password.helper"; +// hooks +import { useInstance } from "@/hooks/store"; +import { AuthService } from "@/services/authentication.service"; + +type Props = { + email: string; + mode: EAuthModes; + handleEmailClear: () => void; + handleStepChange: (step: EAuthSteps) => void; +}; + +type TPasswordFormValues = { + email: string; + password: string; + confirm_password?: string; +}; + +const defaultValues: TPasswordFormValues = { + email: "", + password: "", +}; + +const authService = new AuthService(); + +export const PasswordForm: React.FC = (props) => { + const { email, mode, handleEmailClear, handleStepChange } = props; + // states + const [passwordFormData, setPasswordFormData] = useState({ ...defaultValues, email }); + const [showPassword, setShowPassword] = useState(false); + const [csrfToken, setCsrfToken] = useState(undefined); + const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + // hooks + const { instance } = useInstance(); + // router + const router = useRouter(); + const { next_path } = router.query; + // derived values + const isSmtpConfigured = instance?.config?.is_smtp_configured; + + const handleFormChange = (key: keyof TPasswordFormValues, value: string) => + setPasswordFormData((prev) => ({ ...prev, [key]: value })); + + useEffect(() => { + if (csrfToken === undefined) + authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); + }, [csrfToken]); + + const redirectToUniqueCodeLogin = () => { + handleStepChange(EAuthSteps.UNIQUE_CODE); + }; + + const passwordSupport = + mode === EAuthModes.SIGN_IN ? ( +
+ {isSmtpConfigured ? ( + + Forgot your password? + + ) : ( + + )} +
+ ) : ( + isPasswordInputFocused && + ); + + const isButtonDisabled = useMemo( + () => + !isSubmitting && + !!passwordFormData.password && + (mode === EAuthModes.SIGN_UP + ? getPasswordStrength(passwordFormData.password) >= 3 && + passwordFormData.password === passwordFormData.confirm_password + : true) + ? false + : true, + [isSubmitting, mode, passwordFormData.confirm_password, passwordFormData.password] + ); + + return ( +
setIsSubmitting(true)} + onError={() => setIsSubmitting(false)} + > + + +
+ +
+ handleFormChange("email", e.target.value)} + // hasError={Boolean(errors.email)} + placeholder="name@company.com" + className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" + /> + {passwordFormData.email.length > 0 && ( + + )} +
+
+
+ +
+ handleFormChange("password", e.target.value)} + placeholder="Enter password" + className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" + onFocus={() => setIsPasswordInputFocused(true)} + onBlur={() => setIsPasswordInputFocused(false)} + autoFocus + /> + {showPassword ? ( + setShowPassword(false)} + /> + ) : ( + setShowPassword(true)} + /> + )} +
+ {passwordSupport} +
+ {mode === EAuthModes.SIGN_UP && ( +
+ +
+ handleFormChange("confirm_password", e.target.value)} + placeholder="Confirm password" + className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" + /> + {showPassword ? ( + setShowPassword(false)} + /> + ) : ( + setShowPassword(true)} + /> + )} +
+ {!!passwordFormData.confirm_password && passwordFormData.password !== passwordFormData.confirm_password && ( + Passwords don{"'"}t match + )} +
+ )} +
+ {mode === EAuthModes.SIGN_IN ? ( + <> + + {instance && isSmtpConfigured && ( + + )} + + ) : ( + + )} +
+
+ ); +}; diff --git a/space/components/accounts/auth-forms/root.tsx b/space/components/accounts/auth-forms/root.tsx new file mode 100644 index 000000000..1dac88655 --- /dev/null +++ b/space/components/accounts/auth-forms/root.tsx @@ -0,0 +1,156 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react-lite"; +// components +import { IEmailCheckData } from "@plane/types"; +import { EmailForm, UniqueCodeForm, PasswordForm, OAuthOptions, TermsAndConditions } from "@/components/accounts"; +// hooks +import { useInstance } from "@/hooks/store"; +import useToast from "@/hooks/use-toast"; +// services +import { AuthService } from "@/services/authentication.service"; + +export enum EAuthSteps { + EMAIL = "EMAIL", + PASSWORD = "PASSWORD", + UNIQUE_CODE = "UNIQUE_CODE", +} + +export enum EAuthModes { + SIGN_IN = "SIGN_IN", + SIGN_UP = "SIGN_UP", +} + +type TTitle = { + header: string; + subHeader: string; +}; + +type THeaderSubheader = { + [mode in EAuthModes]: TTitle; +}; + +const titles: THeaderSubheader = { + [EAuthModes.SIGN_IN]: { + header: "Sign in to upvote or comment", + subHeader: "Contribute in nudging the features you want to get built.", + }, + [EAuthModes.SIGN_UP]: { + header: "Comment or react to issues", + subHeader: "Use plane to add your valuable inputs to features.", + }, +}; + +const getHeaderSubHeader = (mode: EAuthModes | null): TTitle => { + if (mode) { + return titles[mode]; + } + + return { + header: "Comment or react to issues", + subHeader: "Use plane to add your valuable inputs to features.", + }; +}; + +const authService = new AuthService(); + +export const AuthRoot = observer(() => { + const { setToastAlert } = useToast(); + // states + const [authMode, setAuthMode] = useState(null); + const [authStep, setAuthStep] = useState(EAuthSteps.EMAIL); + const [email, setEmail] = useState(""); + // hooks + const { instance } = useInstance(); + // derived values + const isSmtpConfigured = instance?.config?.is_smtp_configured; + + const { header, subHeader } = getHeaderSubHeader(authMode); + + const handelEmailVerification = async (data: IEmailCheckData) => { + // update the global email state + setEmail(data.email); + + await authService + .emailCheck(data) + .then((res) => { + // Set authentication mode based on user existing status. + if (res.existing) { + setAuthMode(EAuthModes.SIGN_IN); + } else { + setAuthMode(EAuthModes.SIGN_UP); + } + + // If user exists and password is already setup by the user, move to password sign in. + if (res.existing && !res.is_password_autoset) { + setAuthStep(EAuthSteps.PASSWORD); + } else { + // Else if SMTP is configured, move to unique code sign-in/ sign-up. + if (isSmtpConfigured) { + setAuthStep(EAuthSteps.UNIQUE_CODE); + } else { + // Else show error message if SMTP is not configured and password is not set. + if (res.existing) { + setAuthMode(null); + setToastAlert({ + type: "error", + title: "Error!", + message: "Unable to process request please contact Administrator to reset password", + }); + } else { + // If SMTP is not configured and user is new, move to password sign-up. + setAuthStep(EAuthSteps.PASSWORD); + } + } + } + }) + .catch((err) => + setToastAlert({ + type: "error", + title: "Error!", + message: err?.error ?? "Something went wrong. Please try again.", + }) + ); + }; + + const isOAuthEnabled = + instance?.config && (instance?.config?.is_google_enabled || instance?.config?.is_github_enabled); + return ( +
+
+

{header}

+

{subHeader}

+
+ {authStep === EAuthSteps.EMAIL && } + {authMode && ( + <> + {authStep === EAuthSteps.PASSWORD && ( + { + setEmail(""); + setAuthMode(null); + setAuthStep(EAuthSteps.EMAIL); + }} + handleStepChange={(step) => setAuthStep(step)} + /> + )} + {authStep === EAuthSteps.UNIQUE_CODE && ( + { + setEmail(""); + setAuthMode(null); + setAuthStep(EAuthSteps.EMAIL); + }} + submitButtonText="Continue" + /> + )} + + )} + {isOAuthEnabled && } + +
+ ); +}); diff --git a/space/components/accounts/auth-forms/unique-code.tsx b/space/components/accounts/auth-forms/unique-code.tsx new file mode 100644 index 000000000..bf76acdb9 --- /dev/null +++ b/space/components/accounts/auth-forms/unique-code.tsx @@ -0,0 +1,188 @@ +import React, { useEffect, useState } from "react"; +import { useRouter } from "next/router"; +// icons +import { CircleCheck, XCircle } from "lucide-react"; +// ui +import { Button, Input, Spinner } from "@plane/ui"; +// helpers +import { API_BASE_URL } from "@/helpers/common.helper"; +// hooks +import useTimer from "@/hooks/use-timer"; +import useToast from "@/hooks/use-toast"; +// services +import { AuthService } from "@/services/authentication.service"; +// types +import { IEmailCheckData } from "@/types/auth"; +import { EAuthModes } from "./root"; + +type Props = { + email: string; + mode: EAuthModes; + handleEmailClear: () => void; + submitButtonText: string; +}; + +type TUniqueCodeFormValues = { + email: string; + code: string; +}; + +const defaultValues: TUniqueCodeFormValues = { + email: "", + code: "", +}; + +// services +const authService = new AuthService(); + +export const UniqueCodeForm: React.FC = (props) => { + const { email, mode, handleEmailClear, submitButtonText } = props; + // states + const [uniqueCodeFormData, setUniqueCodeFormData] = useState({ ...defaultValues, email }); + const [isRequestingNewCode, setIsRequestingNewCode] = useState(false); + const [csrfToken, setCsrfToken] = useState(undefined); + const [isSubmitting, setIsSubmitting] = useState(false); + // router + const router = useRouter(); + const { next_path } = router.query; + // toast alert + const { setToastAlert } = useToast(); + // timer + const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(30); + + const handleFormChange = (key: keyof TUniqueCodeFormValues, value: string) => + setUniqueCodeFormData((prev) => ({ ...prev, [key]: value })); + + const handleSendNewCode = async (email: string) => { + const payload: IEmailCheckData = { + email, + }; + + await authService + .generateUniqueCode(payload) + .then(() => { + setResendCodeTimer(30); + setToastAlert({ + type: "success", + title: "Success!", + message: "A new unique code has been sent to your email.", + }); + handleFormChange("code", ""); + }) + .catch((err) => + setToastAlert({ + type: "error", + title: "Error!", + message: err?.error ?? "Something went wrong. Please try again.", + }) + ); + }; + + const handleRequestNewCode = async () => { + setIsRequestingNewCode(true); + + await handleSendNewCode(uniqueCodeFormData.email) + .then(() => setResendCodeTimer(30)) + .finally(() => setIsRequestingNewCode(false)); + }; + + useEffect(() => { + if (csrfToken === undefined) + authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); + }, [csrfToken]); + + useEffect(() => { + setIsRequestingNewCode(true); + handleSendNewCode(email) + .then(() => setResendCodeTimer(30)) + .finally(() => setIsRequestingNewCode(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const isRequestNewCodeDisabled = isRequestingNewCode || resendTimerCode > 0; + const isButtonDisabled = isRequestingNewCode || !uniqueCodeFormData.code || isSubmitting; + + return ( +
setIsSubmitting(true)} + onError={() => setIsSubmitting(false)} + > + + +
+ +
+ handleFormChange("email", e.target.value)} + // FIXME: + // hasError={Boolean(errors.email)} + placeholder="name@company.com" + className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" + disabled + /> + {uniqueCodeFormData.email.length > 0 && ( + + )} +
+
+
+ + handleFormChange("code", e.target.value)} + // FIXME: + // hasError={Boolean(errors.code)} + placeholder="gets-sets-flys" + className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" + autoFocus + /> +
+

+ + Paste the code sent to your email +

+ +
+
+ +
+ ); +}; diff --git a/space/components/accounts/github-sign-in.tsx b/space/components/accounts/github-sign-in.tsx deleted file mode 100644 index 3b9b3f71b..000000000 --- a/space/components/accounts/github-sign-in.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { useEffect, useState, FC } from "react"; -import Image from "next/image"; -import Link from "next/link"; -import { useRouter } from "next/router"; -// next-themes -import { useTheme } from "next-themes"; -// images -import githubBlackImage from "public/logos/github-black.svg"; -import githubWhiteImage from "public/logos/github-white.svg"; - -type Props = { - handleSignIn: React.Dispatch; - clientId: string; -}; - -export const GitHubSignInButton: FC = (props) => { - const { handleSignIn, clientId } = props; - // states - const [loginCallBackURL, setLoginCallBackURL] = useState(undefined); - const [gitCode, setGitCode] = useState(null); - - const router = useRouter(); - - const { code } = router.query; - - const { theme } = useTheme(); - - useEffect(() => { - if (code && !gitCode) { - setGitCode(code.toString()); - handleSignIn(code.toString()); - } - }, [code, gitCode, handleSignIn]); - - useEffect(() => { - const origin = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; - setLoginCallBackURL(`${origin}/` as any); - }, []); - - return ( -
- - - -
- ); -}; diff --git a/space/components/accounts/google-sign-in.tsx b/space/components/accounts/google-sign-in.tsx deleted file mode 100644 index 3d637e949..000000000 --- a/space/components/accounts/google-sign-in.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { FC, useEffect, useRef, useCallback, useState } from "react"; -import Script from "next/script"; - -type Props = { - clientId: string; - handleSignIn: React.Dispatch; -}; - -export const GoogleSignInButton: FC = (props) => { - const { handleSignIn, clientId } = props; - // refs - const googleSignInButton = useRef(null); - // states - const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false); - - const loadScript = useCallback(() => { - if (!googleSignInButton.current || gsiScriptLoaded) return; - - (window as any)?.google?.accounts.id.initialize({ - client_id: clientId, - callback: handleSignIn, - }); - - try { - (window as any)?.google?.accounts.id.renderButton( - googleSignInButton.current, - { - type: "standard", - theme: "outline", - size: "large", - logo_alignment: "center", - text: "signin_with", - width: 384, - } as GsiButtonConfiguration // customization attributes - ); - } catch (err) { - console.log(err); - } - - (window as any)?.google?.accounts.id.prompt(); // also display the One Tap dialog - - setGsiScriptLoaded(true); - }, [handleSignIn, gsiScriptLoaded, clientId]); - - useEffect(() => { - if ((window as any)?.google?.accounts?.id) { - loadScript(); - } - return () => { - (window as any)?.google?.accounts.id.cancel(); - }; - }, [loadScript]); - - return ( - <> -