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/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/layout.tsx b/admin/app/ai/layout.tsx new file mode 100644 index 000000000..64e747a87 --- /dev/null +++ b/admin/app/ai/layout.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { ReactNode } from "react"; +// layouts +import { AuthLayout } 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..764b01c26 --- /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 { InstanceAIForm } from "components/ai"; +import { PageHeader } from "@/components/core"; +// 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/github/page.tsx b/admin/app/authentication/github/page.tsx new file mode 100644 index 000000000..222571657 --- /dev/null +++ b/admin/app/authentication/github/page.tsx @@ -0,0 +1,112 @@ +"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 { AuthenticationMethodCard, InstanceGithubConfigForm } from "components/authentication"; +import { PageHeader } from "@/components/core"; +// 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/page.tsx b/admin/app/authentication/google/page.tsx new file mode 100644 index 000000000..50275aea3 --- /dev/null +++ b/admin/app/authentication/google/page.tsx @@ -0,0 +1,100 @@ +"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 { AuthenticationMethodCard, InstanceGoogleConfigForm } from "components/authentication"; +import { PageHeader } from "@/components/core"; +// 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..30082f442 --- /dev/null +++ b/admin/app/authentication/layout.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { ReactNode } from "react"; +// layouts +import { AuthLayout } 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..db8e533ce --- /dev/null +++ b/admin/app/authentication/page.tsx @@ -0,0 +1,158 @@ +"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, + GoogleConfiguration, + GithubConfiguration, +} from "components/authentication"; +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/layout.tsx b/admin/app/email/layout.tsx new file mode 100644 index 000000000..f6881fcf3 --- /dev/null +++ b/admin/app/email/layout.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { ReactNode } from "react"; +// layouts +import { AuthLayout } 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..88726fb0f --- /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 { InstanceEmailForm } from "components/email"; +import { PageHeader } from "@/components/core"; +// 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/layout.tsx b/admin/app/general/layout.tsx new file mode 100644 index 000000000..2760c0cd3 --- /dev/null +++ b/admin/app/general/layout.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { ReactNode } from "react"; +// layouts +import { AuthLayout } 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..e31e988d4 --- /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 { InstanceGeneralForm } from "@/components/general"; +// 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/layout.tsx b/admin/app/image/layout.tsx new file mode 100644 index 000000000..e00ccb07a --- /dev/null +++ b/admin/app/image/layout.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { ReactNode } from "react"; +// layouts +import { AuthLayout } 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..a2d715006 --- /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/image"; +// 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..41d142a83 --- /dev/null +++ b/admin/app/layout.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { ReactNode } from "react"; +import { ThemeProvider } from "next-themes"; +// lib +import { StoreProvider } from "@/lib/store-context"; +import { AppWrapper } from "@/lib/wrappers"; +// styles +import "./globals.css"; + +interface RootLayoutProps { + children: ReactNode; +} + +const RootLayout = ({ children, ...pageProps }: RootLayoutProps) => ( + + + + + {children} + + + + +); + +export default RootLayout; 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..e10f1b0d7 --- /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/user-authentication-forms"; + +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/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..42779af9a --- /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/user-authentication-forms"; + +const SetupPage = () => ( + <> + + + + + +); + +export default SetupPage; diff --git a/admin/components/ai/ai-config-form.tsx b/admin/components/ai/ai-config-form.tsx new file mode 100644 index 000000000..5290ed1e2 --- /dev/null +++ b/admin/components/ai/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/components/ai/index.ts b/admin/components/ai/index.ts new file mode 100644 index 000000000..8c1763b76 --- /dev/null +++ b/admin/components/ai/index.ts @@ -0,0 +1 @@ +export * from "./ai-config-form"; \ No newline at end of file diff --git a/admin/components/auth-header.tsx b/admin/components/auth-header.tsx new file mode 100644 index 000000000..a356dfaa7 --- /dev/null +++ b/admin/components/auth-header.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { FC } from "react"; +import { usePathname } from "next/navigation"; +// mobx +import { observer } from "mobx-react-lite"; +// ui +import { Settings } from "lucide-react"; +// icons +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "components/common"; +import { SidebarHamburgerToggle } from "@/components/auth-sidebar"; + +export const InstanceHeader: FC = observer(() => { + const pathName = usePathname(); + + const getHeaderTitle = (pathName: string) => { + switch (pathName) { + case "general": + return "General"; + case "ai": + return "Artificial Intelligence"; + case "email": + return "Email"; + case "authentication": + return "Authentication"; + case "image": + return "Image"; + case "google": + return "Google"; + case "github": + return "Github"; + default: + return pathName.toUpperCase(); + } + }; + + // Function to dynamically generate breadcrumb items based on pathname + const generateBreadcrumbItems = (pathname: string) => { + const pathSegments = pathname.split("/").slice(1); // removing the first empty string. + pathSegments.pop(); + + let currentUrl = ""; + const breadcrumbItems = pathSegments.map((segment) => { + currentUrl += "/" + segment; + return { + title: getHeaderTitle(segment), + href: currentUrl, + }; + }); + return breadcrumbItems; + }; + + const breadcrumbItems = generateBreadcrumbItems(pathName); + + return ( +
+
+ + {breadcrumbItems.length >= 0 && ( +
+ + } + /> + } + /> + {breadcrumbItems.map( + (item) => + item.title && ( + } + /> + ) + )} + +
+ )} +
+
+ ); +}); diff --git a/admin/components/auth-sidebar/help-section.tsx b/admin/components/auth-sidebar/help-section.tsx new file mode 100644 index 000000000..b2cba645d --- /dev/null +++ b/admin/components/auth-sidebar/help-section.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { FC, useState, useRef } from "react"; +import { Transition } from "@headlessui/react"; +import Link from "next/link"; +import { FileText, HelpCircle, MoveLeft } from "lucide-react"; +// hooks +import { useTheme } from "@/hooks"; +// icons +import { DiscordIcon, GithubIcon } from "@plane/ui"; +// assets +import packageJson from "package.json"; + +const helpOptions = [ + { + name: "Documentation", + href: "https://docs.plane.so/", + Icon: FileText, + }, + { + name: "Join our Discord", + href: "https://discord.com/invite/A92xrEGCge", + Icon: DiscordIcon, + }, + { + name: "Report a bug", + href: "https://github.com/makeplane/plane/issues/new/choose", + Icon: GithubIcon, + }, +]; + +export const HelpSection: FC = () => { + // states + const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false); + // store + const { isSidebarCollapsed, toggleSidebar } = useTheme(); + // refs + const helpOptionsRef = useRef(null); + + return ( +
+
+ + + +
+ +
+ +
+
+ {helpOptions.map(({ name, Icon, href }) => { + if (href) + return ( + +
+
+ +
+ {name} +
+ + ); + else + return ( + + ); + })} +
+
Version: v{packageJson.version}
+
+
+
+
+ ); +}; diff --git a/admin/components/auth-sidebar/index.ts b/admin/components/auth-sidebar/index.ts new file mode 100644 index 000000000..e800fe3c5 --- /dev/null +++ b/admin/components/auth-sidebar/index.ts @@ -0,0 +1,5 @@ +export * from "./root"; +export * from "./help-section"; +export * from "./sidebar-menu"; +export * from "./sidebar-dropdown"; +export * from "./sidebar-menu-hamburger-toogle"; diff --git a/admin/components/auth-sidebar/root.tsx b/admin/components/auth-sidebar/root.tsx new file mode 100644 index 000000000..d29247431 --- /dev/null +++ b/admin/components/auth-sidebar/root.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { FC, useEffect, useRef } from "react"; +import { observer } from "mobx-react-lite"; +// hooks +import { useTheme } from "@/hooks"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; +// components +import { HelpSection, SidebarMenu, SidebarDropdown } from "@/components/auth-sidebar"; + +export interface IInstanceSidebar {} + +export const InstanceSidebar: FC = observer(() => { + // store + const { isSidebarCollapsed, toggleSidebar } = useTheme(); + + const ref = useRef(null); + + useOutsideClickDetector(ref, () => { + if (isSidebarCollapsed === false) { + if (window.innerWidth < 768) { + toggleSidebar(!isSidebarCollapsed); + } + } + }); + + useEffect(() => { + const handleResize = () => { + if (window.innerWidth <= 768) { + toggleSidebar(true); + } + }; + handleResize(); + window.addEventListener("resize", handleResize); + return () => { + window.removeEventListener("resize", handleResize); + }; + }, [toggleSidebar]); + + return ( +
+
+ + + +
+
+ ); +}); diff --git a/admin/components/auth-sidebar/sidebar-dropdown.tsx b/admin/components/auth-sidebar/sidebar-dropdown.tsx new file mode 100644 index 000000000..66dbf95c3 --- /dev/null +++ b/admin/components/auth-sidebar/sidebar-dropdown.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { Fragment } from "react"; +// import { useRouter } from "next/navigation"; +import { useTheme as useNextTheme } from "next-themes"; +import { observer } from "mobx-react-lite"; +// import { mutate } from "swr"; +// components +import { Menu, Transition } from "@headlessui/react"; +// icons +import { LogOut, UserCog2, Palette } from "lucide-react"; +// hooks +import { useTheme, useUser } from "@/hooks"; + +// ui +import { Avatar, TOAST_TYPE, setToast } from "@plane/ui"; + +export const SidebarDropdown = observer(() => { + // store hooks + const { isSidebarCollapsed } = useTheme(); + const { currentUser, signOut } = useUser(); + // hooks + const { resolvedTheme, setTheme } = useNextTheme(); + + const handleSignOut = async () => { + await signOut().catch(() => + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Failed to sign out. Please try again.", + }) + ); + }; + + const handleThemeSwitch = () => { + const newTheme = resolvedTheme === "dark" ? "light" : "dark"; + setTheme(newTheme); + }; + + return ( +
+
+
+
+ +
+ + {!isSidebarCollapsed && ( +
+

Instance admin

+
+ )} +
+
+ + {!isSidebarCollapsed && currentUser && ( + + + + + + + +
+ {currentUser?.email} +
+
+ + + Switch to {resolvedTheme === "dark" ? "light" : "dark"} mode + +
+
+ + + Sign out + +
+
+
+
+ )} +
+ ); +}); diff --git a/admin/components/auth-sidebar/sidebar-menu-hamburger-toogle.tsx b/admin/components/auth-sidebar/sidebar-menu-hamburger-toogle.tsx new file mode 100644 index 000000000..ba00afa7f --- /dev/null +++ b/admin/components/auth-sidebar/sidebar-menu-hamburger-toogle.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react-lite"; +// hooks +import { useTheme } from "@/hooks"; +// icons +import { Menu } from "lucide-react"; + +export const SidebarHamburgerToggle: FC = observer(() => { + const { isSidebarCollapsed, toggleSidebar } = useTheme(); + return ( +
toggleSidebar(!isSidebarCollapsed)} + > + +
+ ); +}); diff --git a/admin/components/auth-sidebar/sidebar-menu.tsx b/admin/components/auth-sidebar/sidebar-menu.tsx new file mode 100644 index 000000000..4aab04f80 --- /dev/null +++ b/admin/components/auth-sidebar/sidebar-menu.tsx @@ -0,0 +1,104 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { observer } from "mobx-react-lite"; +import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react"; +import { Tooltip } from "@plane/ui"; +// hooks +import { useTheme } from "@/hooks"; +// helpers +import { cn } from "@/helpers/common.helper"; + +const INSTANCE_ADMIN_LINKS = [ + { + Icon: Cog, + name: "General", + description: "Identify your instances and get key details", + href: `/general/`, + }, + { + Icon: Mail, + name: "Email", + description: "Set up emails to your users", + href: `/email/`, + }, + { + Icon: Lock, + name: "Authentication", + description: "Configure authentication modes", + href: `/authentication/`, + }, + { + Icon: BrainCog, + name: "Artificial intelligence", + description: "Configure your OpenAI creds", + href: `/ai/`, + }, + { + Icon: Image, + name: "Images in Plane", + description: "Allow third-party image libraries", + href: `/image/`, + }, +]; + +export const SidebarMenu = observer(() => { + // store hooks + const { isSidebarCollapsed, toggleSidebar } = useTheme(); + // router + const pathName = usePathname(); + + const handleItemClick = () => { + if (window.innerWidth < 768) { + toggleSidebar(!isSidebarCollapsed); + } + }; + + return ( +
+ {INSTANCE_ADMIN_LINKS.map((item, index) => { + const isActive = item.href === pathName || pathName.includes(item.href); + return ( + +
+ +
+ {} + {!isSidebarCollapsed && ( +
+
+ {item.name} +
+
+ {item.description} +
+
+ )} +
+
+
+ + ); + })} +
+ ); +}); diff --git a/admin/components/authentication/authentication-method-card.tsx b/admin/components/authentication/authentication-method-card.tsx new file mode 100644 index 000000000..1346a730e --- /dev/null +++ b/admin/components/authentication/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/components/authentication/email-codes/index.ts b/admin/components/authentication/email-codes/index.ts new file mode 100644 index 000000000..50a9c47c0 --- /dev/null +++ b/admin/components/authentication/email-codes/index.ts @@ -0,0 +1 @@ +export * from "./root"; \ No newline at end of file diff --git a/admin/components/authentication/email-codes/root.tsx b/admin/components/authentication/email-codes/root.tsx new file mode 100644 index 000000000..0958b3c42 --- /dev/null +++ b/admin/components/authentication/email-codes/root.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/components/authentication/github/github-config-form.tsx b/admin/components/authentication/github/github-config-form.tsx new file mode 100644 index 000000000..22eb11ff4 --- /dev/null +++ b/admin/components/authentication/github/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/components/authentication/github/index.ts b/admin/components/authentication/github/index.ts new file mode 100644 index 000000000..e9e36e988 --- /dev/null +++ b/admin/components/authentication/github/index.ts @@ -0,0 +1,2 @@ +export * from "./root"; +export * from "./github-config-form"; \ No newline at end of file diff --git a/admin/components/authentication/github/root.tsx b/admin/components/authentication/github/root.tsx new file mode 100644 index 000000000..742462c3b --- /dev/null +++ b/admin/components/authentication/github/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/components/authentication/google/google-config-form.tsx b/admin/components/authentication/google/google-config-form.tsx new file mode 100644 index 000000000..42cea78fd --- /dev/null +++ b/admin/components/authentication/google/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/components/authentication/google/index.ts b/admin/components/authentication/google/index.ts new file mode 100644 index 000000000..d0d37f305 --- /dev/null +++ b/admin/components/authentication/google/index.ts @@ -0,0 +1,2 @@ +export * from "./root"; +export * from "./google-config-form"; \ No newline at end of file diff --git a/admin/components/authentication/google/root.tsx b/admin/components/authentication/google/root.tsx new file mode 100644 index 000000000..6b287476d --- /dev/null +++ b/admin/components/authentication/google/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/components/authentication/index.ts b/admin/components/authentication/index.ts new file mode 100644 index 000000000..bbd14f3a6 --- /dev/null +++ b/admin/components/authentication/index.ts @@ -0,0 +1,5 @@ +export * from "./authentication-method-card"; +export * from "./email-codes"; +export * from "./password"; +export * from "./google"; +export * from "./github"; diff --git a/admin/components/authentication/password/index.ts b/admin/components/authentication/password/index.ts new file mode 100644 index 000000000..50a9c47c0 --- /dev/null +++ b/admin/components/authentication/password/index.ts @@ -0,0 +1 @@ +export * from "./root"; \ No newline at end of file diff --git a/admin/components/authentication/password/root.tsx b/admin/components/authentication/password/root.tsx new file mode 100644 index 000000000..92428e494 --- /dev/null +++ b/admin/components/authentication/password/root.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/components/common/banner.tsx b/admin/components/common/banner.tsx new file mode 100644 index 000000000..13d1583a2 --- /dev/null +++ b/admin/components/common/banner.tsx @@ -0,0 +1,30 @@ +import { FC } from "react"; +import { AlertCircle, CheckCircle } from "lucide-react"; + +type TBanner = { + type: "success" | "error"; + message: string; +}; + +export const Banner: FC = (props) => { + const { type, message } = props; + + return ( +
+
+
+ {type === "error" ? ( + + + ) : ( +
+
+

{message}

+
+
+
+ ); +}; diff --git a/admin/components/common/breadcrumb-link.tsx b/admin/components/common/breadcrumb-link.tsx new file mode 100644 index 000000000..dfa437231 --- /dev/null +++ b/admin/components/common/breadcrumb-link.tsx @@ -0,0 +1,36 @@ +import Link from "next/link"; +import { Tooltip } from "@plane/ui"; + +type Props = { + label?: string; + href?: string; + icon?: React.ReactNode | undefined; +}; + +export const BreadcrumbLink: React.FC = (props) => { + const { href, label, icon } = props; + return ( + +
  • +
    + {href ? ( + + {icon && ( +
    {icon}
    + )} +
    {label}
    + + ) : ( +
    + {icon &&
    {icon}
    } +
    {label}
    +
    + )} +
    +
  • +
    + ); +}; diff --git a/admin/components/common/confirm-discard-modal.tsx b/admin/components/common/confirm-discard-modal.tsx new file mode 100644 index 000000000..64e4d7a08 --- /dev/null +++ b/admin/components/common/confirm-discard-modal.tsx @@ -0,0 +1,83 @@ +import React from "react"; +import Link from "next/link"; +// headless ui +import { Dialog, Transition } from "@headlessui/react"; +// ui +import { Button, getButtonStyling } from "@plane/ui"; + +type Props = { + isOpen: boolean; + handleClose: () => void; + onDiscardHref: string; +}; + +export const ConfirmDiscardModal: React.FC = (props) => { + const { isOpen, handleClose, onDiscardHref } = props; + + return ( + + + +
    + +
    +
    + + +
    +
    +
    + + You have unsaved changes + +
    +

    + Changes you made will be lost if you go back. Do you + wish to go back? +

    +
    +
    +
    +
    +
    + + + Go back + +
    +
    +
    +
    +
    +
    +
    + ); +}; diff --git a/admin/components/common/controller-input.tsx b/admin/components/common/controller-input.tsx new file mode 100644 index 000000000..a0990a057 --- /dev/null +++ b/admin/components/common/controller-input.tsx @@ -0,0 +1,89 @@ +"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"; + +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..5b64a8b02 --- /dev/null +++ b/admin/components/core/page-header.tsx @@ -0,0 +1,17 @@ +import Head from "next/head"; + +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/create-workspace-popup.tsx b/admin/components/create-workspace-popup.tsx new file mode 100644 index 000000000..1b73860c4 --- /dev/null +++ b/admin/components/create-workspace-popup.tsx @@ -0,0 +1,61 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react-lite"; +import Link from "next/link"; +import Image from "next/image"; +import { useTheme } 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"; + +type Props = { + isOpen: boolean; + onClose?: () => void; +}; + +export const CreateWorkspacePopup: React.FC = observer((props) => { + const { isOpen, onClose } = props; + // theme + const { resolvedTheme } = useTheme(); + + const handleClose = () => { + onClose && onClose(); + }; + + if (!isOpen) return null; + + 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/components/email/email-config-form.tsx b/admin/components/email/email-config-form.tsx new file mode 100644 index 000000000..12e01b92c --- /dev/null +++ b/admin/components/email/email-config-form.tsx @@ -0,0 +1,160 @@ +import { FC, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +// hooks +import { useInstance } from "@/hooks"; +// ui +import { Button, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui"; +// components +import { ControllerInput, TControllerInputFormField } from "components/common"; +import { SendTestEmailModal } from "./send-test-email-modal"; +// types +import { IFormattedInstanceConfiguration, TInstanceEmailConfigurationKeys } from "@plane/types"; + +type IInstanceEmailForm = { + config: IFormattedInstanceConfiguration; +}; + +type EmailFormValues = Record; + +export const InstanceEmailForm: FC = (props) => { + const { config } = props; + // states + const [isSendTestEmailModalOpen, setIsSendTestEmailModalOpen] = useState(false); + // store hooks + const { updateInstanceConfigurations } = useInstance(); + // form data + const { + handleSubmit, + watch, + control, + formState: { errors, 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_HOST_USER", + type: "text", + label: "Username", + placeholder: "getitdone@projectplane.so", + error: Boolean(errors.EMAIL_HOST_USER), + required: true, + }, + { + key: "EMAIL_HOST_PASSWORD", + type: "password", + label: "Password", + placeholder: "Password", + error: Boolean(errors.EMAIL_HOST_PASSWORD), + required: true, + }, + { + key: "EMAIL_FROM", + type: "text", + label: "From 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 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)); + }; + + return ( +
    +
    + setIsSendTestEmailModalOpen(false)} /> +
    + {emailFormFields.map((field) => ( + + ))} +
    +
    +
    +
    +
    + Turn TLS {Boolean(parseInt(watch("EMAIL_USE_TLS"))) ? "off" : "on"} +
    +
    + Use this if your email domain supports TLS. +
    +
    +
    + ( + { + Boolean(parseInt(value)) === true ? onChange("0") : onChange("1"); + }} + size="sm" + /> + )} + /> +
    +
    +
    +
    + +
    + + +
    +
    + ); +}; diff --git a/admin/components/email/index.ts b/admin/components/email/index.ts new file mode 100644 index 000000000..6ad74f4e8 --- /dev/null +++ b/admin/components/email/index.ts @@ -0,0 +1,2 @@ +export * from "./email-config-form"; +export * from "./send-test-email-modal"; diff --git a/admin/components/email/send-test-email-modal.tsx b/admin/components/email/send-test-email-modal.tsx new file mode 100644 index 000000000..6e9f6d9d3 --- /dev/null +++ b/admin/components/email/send-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/components/general/general-form.tsx b/admin/components/general/general-form.tsx new file mode 100644 index 000000000..a5ac0706d --- /dev/null +++ b/admin/components/general/general-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 IInstanceGeneralForm { + instance: IInstance["instance"]; + instanceAdmins: IInstanceAdmin[]; +} + +export const InstanceGeneralForm: 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/components/general/index.ts b/admin/components/general/index.ts new file mode 100644 index 000000000..18daed803 --- /dev/null +++ b/admin/components/general/index.ts @@ -0,0 +1 @@ +export * from "./general-form"; \ No newline at end of file diff --git a/admin/components/image/image-config-form.tsx b/admin/components/image/image-config-form.tsx new file mode 100644 index 000000000..722051878 --- /dev/null +++ b/admin/components/image/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/components/image/index.ts b/admin/components/image/index.ts new file mode 100644 index 000000000..ad9b60a10 --- /dev/null +++ b/admin/components/image/index.ts @@ -0,0 +1 @@ +export * from "./image-config-form"; \ No newline at end of file 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..067599021 --- /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/user-authentication-forms/index.ts b/admin/components/user-authentication-forms/index.ts new file mode 100644 index 000000000..fcf3e7c9a --- /dev/null +++ b/admin/components/user-authentication-forms/index.ts @@ -0,0 +1,2 @@ +export * from "./sign-up"; +export * from "./sign-in"; diff --git a/admin/components/user-authentication-forms/sign-in.tsx b/admin/components/user-authentication-forms/sign-in.tsx new file mode 100644 index 000000000..ba0883c83 --- /dev/null +++ b/admin/components/user-authentication-forms/sign-in.tsx @@ -0,0 +1,162 @@ +"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 } from "@plane/ui"; +// components +import { Banner } from "components/common"; +// icons +import { Eye, EyeOff } from "lucide-react"; +// helpers +import { API_BASE_URL, cn } 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 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(() => (formData.email && formData.password ? false : true), [formData]); + + return ( +
    +
    +
    +

    Manage your Plane instance

    +

    Configure instance-wide settings to secure your instance

    +
    + + {errorData.type && errorData?.message && } + +
    + + +
    + + handleFormChange("email", e.target.value)} + /> +
    + +
    + +
    + handleFormChange("password", e.target.value)} + /> + {showPassword ? ( + + ) : ( + + )} +
    +
    +
    + +
    +
    +
    +
    + ); +}; diff --git a/admin/components/user-authentication-forms/sign-up.tsx b/admin/components/user-authentication-forms/sign-up.tsx new file mode 100644 index 000000000..d700ce62c --- /dev/null +++ b/admin/components/user-authentication-forms/sign-up.tsx @@ -0,0 +1,267 @@ +"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 } from "@plane/ui"; +// components +import { Banner, PasswordStrengthMeter } from "components/common"; +// icons +import { Eye, EyeOff } from "lucide-react"; +// helpers +import { API_BASE_URL, cn } 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; + 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 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( + () => + formData.first_name && formData.email && formData.password && getPasswordStrength(formData.password) >= 3 + ? false + : true, + [formData] + ); + + 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) && ( + + )} + +
    + + +
    +
    + + handleFormChange("first_name", e.target.value)} + /> +
    +
    + + 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} + /> + {showPassword ? ( + + ) : ( + + )} +
    + {errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD && errorData.message && ( +

    {errorData.message}

    + )} + +
    + +
    + handleFormChange("is_telemetry_enabled", !formData.is_telemetry_enabled)} + checked={formData.is_telemetry_enabled} + /> + + + See More + +
    + +
    + +
    +
    +
    +
    + ); +}; 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/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/auth-layout.tsx b/admin/layouts/auth-layout.tsx new file mode 100644 index 000000000..61d606964 --- /dev/null +++ b/admin/layouts/auth-layout.tsx @@ -0,0 +1,21 @@ +import { FC, ReactNode } from "react"; +import { InstanceSidebar } from "@/components/auth-sidebar"; +import { InstanceHeader } from "@/components/auth-header"; + +type TAuthLayout = { + children: ReactNode; +}; + +export const AuthLayout: 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..f60258cd6 --- /dev/null +++ b/admin/layouts/default-layout.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { FC, ReactNode } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +// logo +import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png"; + +type TDefaultLayout = { + children: ReactNode; +}; + +export const DefaultLayout: FC = (props) => { + const { children } = props; + const pathname = usePathname(); + + console.log("pathname", pathname); + + return ( +
    +
    +
    +
    + Plane Logo + Plane +
    +
    +
    +
    {children}
    +
    + ); +}; diff --git a/admin/layouts/index.ts b/admin/layouts/index.ts new file mode 100644 index 000000000..bf6743bc1 --- /dev/null +++ b/admin/layouts/index.ts @@ -0,0 +1,2 @@ +export * from "./default-layout"; +export * from "./auth-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..4edbcbde4 --- /dev/null +++ b/admin/lib/wrappers/instance-wrapper.tsx @@ -0,0 +1,58 @@ +"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..85b87e91f --- /dev/null +++ b/admin/next.config.js @@ -0,0 +1,13 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + trailingSlash: true, + reactStrictMode: false, + swcMinify: true, + output: "standalone", + images: { + unoptimized: true, + }, + basePath: process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX === "1" ? "/god-mode" : "", +}; + +module.exports = nextConfig; diff --git a/admin/package.json b/admin/package.json new file mode 100644 index 000000000..e0913d094 --- /dev/null +++ b/admin/package.json @@ -0,0 +1,48 @@ +{ + "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": { + "@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.1.0", + "next-themes": "^0.2.1", + "postcss": "8.4.23", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "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/images/plane-takeoff.png b/admin/public/images/plane-takeoff.png new file mode 100644 index 000000000..417ff8299 Binary files /dev/null and b/admin/public/images/plane-takeoff.png differ diff --git a/admin/public/logos/github-black.png b/admin/public/logos/github-black.png new file mode 100644 index 000000000..7a7a82474 Binary files /dev/null and b/admin/public/logos/github-black.png differ diff --git a/admin/public/logos/github-white.png b/admin/public/logos/github-white.png new file mode 100644 index 000000000..dbb2b578c Binary files /dev/null and b/admin/public/logos/github-white.png differ diff --git a/admin/public/logos/google-logo.svg b/admin/public/logos/google-logo.svg new file mode 100644 index 000000000..088288fa3 --- /dev/null +++ b/admin/public/logos/google-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/public/logos/takeoff-icon-dark.svg b/admin/public/logos/takeoff-icon-dark.svg new file mode 100644 index 000000000..d3ef19119 --- /dev/null +++ b/admin/public/logos/takeoff-icon-dark.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/admin/public/logos/takeoff-icon-light.svg b/admin/public/logos/takeoff-icon-light.svg new file mode 100644 index 000000000..97cf43fe7 --- /dev/null +++ b/admin/public/logos/takeoff-icon-light.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/admin/public/plane-logos/blue-without-text.png b/admin/public/plane-logos/blue-without-text.png new file mode 100644 index 000000000..ea94aec79 Binary files /dev/null and b/admin/public/plane-logos/blue-without-text.png differ diff --git a/admin/services/api.service.ts b/admin/services/api.service.ts new file mode 100644 index 000000000..5de7196aa --- /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.response?.data ?? 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..c67db9cb6 --- /dev/null +++ b/admin/services/auth.service.ts @@ -0,0 +1,45 @@ +// 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; + }); + } + + async signOut(baseUrl: string): Promise { + await this.requestCSRFToken().then((data) => { + const csrfToken = data?.csrf_token; + + if (!csrfToken) throw Error("CSRF token not found"); + + var form = document.createElement("form"); + var element1 = document.createElement("input"); + + form.method = "POST"; + form.action = `${baseUrl}/api/instances/admins/sign-out/`; + + element1.value = csrfToken; + element1.name = "csrfmiddlewaretoken"; + element1.type = "hidden"; + form.appendChild(element1); + + document.body.appendChild(form); + + form.submit(); + }); + } +} diff --git a/admin/services/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..fdc46e99b --- /dev/null +++ b/admin/store/instance.store.ts @@ -0,0 +1,161 @@ +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(); + 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..aa695f1cf --- /dev/null +++ b/admin/store/theme.store.ts @@ -0,0 +1,53 @@ +import { action, observable, makeObservable } from "mobx"; +// root store +import { RootStore } from "@/store/root-store"; + +type TTheme = "dark" | "light"; +export interface IThemeStore { + // observables + theme: string | undefined; + isSidebarCollapsed: boolean | undefined; + // actions + toggleSidebar: (collapsed: boolean) => void; + setTheme: (currentTheme: TTheme) => void; +} + +export class ThemeStore implements IThemeStore { + // observables + isSidebarCollapsed: boolean | undefined = undefined; + theme: string | undefined = undefined; + + constructor(private store: RootStore) { + makeObservable(this, { + // observables + isSidebarCollapsed: observable.ref, + theme: observable.ref, + // action + toggleSidebar: action, + setTheme: action, + }); + } + + /** + * 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()); + }; + + /** + * 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..0a7895e7b --- /dev/null +++ b/admin/store/user.store.ts @@ -0,0 +1,85 @@ +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"; +import { API_BASE_URL } from "@/helpers/common.helper"; + +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 () => { + await this.authService.signOut(API_BASE_URL); + 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 3d7603e24..0268f673e 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, WebhookMixin @@ -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 1908cfdc9..42cac04fb 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.bgtasks.webhook_task import send_webhook from plane.utils.exception_logger import log_exception from plane.utils.paginator import BasePaginator @@ -79,6 +80,10 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): SearchFilter, ) + authentication_classes = [ + BaseSessionAuthentication, + ] + filterset_fields = [] search_fields = [] @@ -191,6 +196,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..9c5c83a4a --- /dev/null +++ b/apiserver/plane/authentication/adapter/base.py @@ -0,0 +1,120 @@ +# Python imports +import os +import uuid + +# Django imports +from django.core.exceptions import ImproperlyConfigured +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 + + +class AuthenticationException(Exception): + + error_code = None + error_message = None + + def __init__(self, error_code, error_message): + self.error_code = error_code + self.error_message = error_message + + +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 ImproperlyConfigured( + "Account creation is disabled for this instance please contact your admin" + ) + 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_message="The password is not a valid password", + error_code="INVALID_PASSWORD", + ) + + 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/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..697881e35 --- /dev/null +++ b/apiserver/plane/authentication/middleware/session.py @@ -0,0 +1,87 @@ +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. + cookie_name = ( + settings.ADMIN_SESSION_COOKIE_NAME + if "instances" in request.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: + max_age = request.session.get_expiry_age() + expires_time = time.time() + max_age + expires = http_date(expires_time) + # Save the session data and refresh the client cookie. + # Skip session save for 5xx responses. + if response.status_code < 500: + try: + request.session.save() + 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..77c86da30 --- /dev/null +++ b/apiserver/plane/authentication/provider/credentials/email.py @@ -0,0 +1,75 @@ +# Module imports +from plane.authentication.adapter.base import AuthenticationException +from plane.authentication.adapter.credential import CredentialAdapter +from plane.db.models import User + + +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 with this email already exists", + error_code="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() + # Existing user + if not user: + raise AuthenticationException( + error_message="Sorry, we could not find a user with the provided credentials. Please try again.", + error_code="AUTHENTICATION_FAILED", + ) + + # Check user password + if not user.check_password(self.code): + raise AuthenticationException( + error_message="Sorry, we could not find a user with the provided credentials. Please try again.", + error_code="AUTHENTICATION_FAILED", + ) + + 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..d49f19429 --- /dev/null +++ b/apiserver/plane/authentication/provider/credentials/magic_code.py @@ -0,0 +1,123 @@ +# Python imports +import json +import os +import random +import string + +# Django imports +from django.core.exceptions import ImproperlyConfigured + +# Module imports +from plane.authentication.adapter.base import AuthenticationException +from plane.authentication.adapter.credential import CredentialAdapter +from plane.license.utils.instance_value import get_configuration_value +from plane.settings.redis import redis_instance + + +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 and EMAIL_HOST_USER and EMAIL_HOST_PASSWORD): + raise ImproperlyConfigured( + "SMTP is not configured. Please contact the support team." + ) + + 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_message="The token is not valid.", + error_code="INVALID_TOKEN", + ) + else: + raise AuthenticationException( + error_message="The token has expired. Please regenerate the token and try again.", + error_code="EXPIRED_TOKEN", + ) 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..ad8d913a1 --- /dev/null +++ b/apiserver/plane/authentication/provider/oauth/github.py @@ -0,0 +1,134 @@ +# Python imports +import os +from datetime import datetime +from urllib.parse import urlencode + +import pytz +import requests + +# Django imports +from django.core.exceptions import ImproperlyConfigured + +# Module imports +from plane.authentication.adapter.oauth import OauthAdapter +from plane.license.utils.instance_value import get_configuration_value + + +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 ImproperlyConfigured( + "Google is not configured. Please contact the support team." + ) + + 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..94a827c9d --- /dev/null +++ b/apiserver/plane/authentication/provider/oauth/google.py @@ -0,0 +1,115 @@ +# Python imports +import os +from datetime import datetime +from urllib.parse import urlencode + +import pytz + +# Django imports +from django.core.exceptions import ImproperlyConfigured + +# Module imports +from plane.authentication.adapter.oauth import OauthAdapter +from plane.license.utils.instance_value import get_configuration_value + + +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 ImproperlyConfigured( + "Google is not configured. Please contact the support team." + ) + + 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..80f492d53 --- /dev/null +++ b/apiserver/plane/authentication/utils/host.py @@ -0,0 +1,10 @@ +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()}" + ) diff --git a/apiserver/plane/authentication/utils/login.py b/apiserver/plane/authentication/utils/login.py new file mode 100644 index 000000000..7dc2eb1ca --- /dev/null +++ b/apiserver/plane/authentication/utils/login.py @@ -0,0 +1,12 @@ +from django.contrib.auth import login + + +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", ""), + } + 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..54b39ed6f --- /dev/null +++ b/apiserver/plane/authentication/views/app/check.py @@ -0,0 +1,82 @@ +# 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 + + +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: + return Response( + { + "error_code": "INSTANCE_NOT_CONFIGURED", + "error_message": "Instance is not configured", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + email = request.data.get("email", False) + existing_user = User.objects.filter(email=email).first() + + if existing_user: + return Response( + { + "error_code": "USER_ALREADY_EXIST", + "error_message": "User already exists with the email.", + }, + 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: + return Response( + { + "error_code": "INSTANCE_NOT_CONFIGURED", + "error_message": "Instance is not configured", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + email = request.data.get("email", False) + 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, + ) + return Response( + { + "error_code": "USER_DOES_NOT_EXIST", + "error_message": "User could not be found with the given email.", + }, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apiserver/plane/authentication/views/app/email.py b/apiserver/plane/authentication/views/app/email.py new file mode 100644 index 000000000..894af3cbb --- /dev/null +++ b/apiserver/plane/authentication/views/app/email.py @@ -0,0 +1,218 @@ +# 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.adapter.base import AuthenticationException +from plane.authentication.provider.credentials.email import EmailProvider +from plane.authentication.utils.login import user_login +from plane.license.models import Instance +from plane.authentication.utils.host import base_host +from plane.authentication.utils.redirection_path import get_redirection_path +from plane.authentication.utils.workspace_project_join import ( + process_workspace_project_invitations, +) +from plane.db.models import User + + +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 + params = { + "error_code": "REQUIRED_EMAIL_PASSWORD", + "error_message": "Both email and password are required", + } + 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 + params = { + "error_code": "REQUIRED_EMAIL_PASSWORD", + "error_message": "Both email and password are required", + } + # 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: + params = { + "error_code": "INVALID_EMAIL", + "error_message": "Please provide a valid email address.", + } + 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(): + 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 = 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 = { + "error_code": str(e.error_code), + "error_message": str(e.error_message), + } + 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: + params = { + "error_code": "INSTANCE_NOT_CONFIGURED", + "error_message": "Instance is not configured", + } + 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: + params = { + "error_code": "REQUIRED_EMAIL_PASSWORD", + "error_message": "Both email and password are required", + } + 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: + params = { + "error_code": "INVALID_EMAIL", + "error_message": "Please provide a valid email address.", + } + 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(): + params = { + "error_code": "USER_ALREADY_EXIST", + "error_message": "User already exists with the email.", + } + 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 = { + "error_code": str(e.error_code), + "error_message": str(e.error_message), + } + 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..4d299ef4f --- /dev/null +++ b/apiserver/plane/authentication/views/app/github.py @@ -0,0 +1,124 @@ +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 + + +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: + params = { + "error_code": "INSTANCE_NOT_CONFIGURED", + "error_message": "Instance is not configured", + } + 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 ImproperlyConfigured as e: + params = { + "error_code": "IMPROPERLY_CONFIGURED", + "error_message": str(e), + } + 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", ""): + params = { + "error_code": "OAUTH_PROVIDER_ERROR", + "error_message": "State does not match", + } + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host, + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + if not code: + params = { + "error_code": "OAUTH_PROVIDER_ERROR", + "error_message": "Something went wrong while fetching data from OAuth provider. Please try again after sometime.", + } + 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 ImproperlyConfigured as e: + params = { + "error_code": "IMPROPERLY_CONFIGURED", + "error_message": str(e), + } + 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..bbadc0066 --- /dev/null +++ b/apiserver/plane/authentication/views/app/google.py @@ -0,0 +1,121 @@ +# 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 + + +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: + params = { + "error_code": "INSTANCE_NOT_CONFIGURED", + "error_message": "Instance is not configured", + } + 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 ImproperlyConfigured as e: + params = { + "error_code": "IMPROPERLY_CONFIGURED", + "error_message": str(e), + } + 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", ""): + params = { + "error_code": "OAUTH_PROVIDER_ERROR", + "error_message": "State does not match", + } + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host, + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + if not code: + params = { + "error_code": "OAUTH_PROVIDER_ERROR", + "error_message": "Something went wrong while fetching data from OAuth provider. Please try again after sometime.", + } + 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 ImproperlyConfigured as e: + params = { + "error_code": "IMPROPERLY_CONFIGURED", + "error_message": str(e), + } + 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..da14acbef --- /dev/null +++ b/apiserver/plane/authentication/views/app/magic.py @@ -0,0 +1,221 @@ +# 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.adapter.base import AuthenticationException +from plane.authentication.provider.credentials.magic_code import ( + MagicCodeProvider, +) +from plane.authentication.utils.login import user_login +from plane.authentication.utils.redirection_path import get_redirection_path +from plane.authentication.utils.workspace_project_join import ( + process_workspace_project_invitations, +) +from plane.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 + + +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: + return Response( + { + "error_code": "INSTANCE_NOT_CONFIGURED", + "error_message": "Instance is not configured", + } + ) + + 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 ImproperlyConfigured as e: + return Response( + { + "error_code": "IMPROPERLY_CONFIGURED", + "error_message": str(e), + }, + status=status.HTTP_400_BAD_REQUEST, + ) + except AuthenticationException as e: + return Response( + { + "error_code": str(e.error_code), + "error_message": str(e.error_message), + }, + status=status.HTTP_400_BAD_REQUEST, + ) + except ValidationError: + return Response( + { + "error_code": "INVALID_EMAIL", + "error_message": "Valid email is required for generating a magic code", + }, + 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 == "": + params = { + "error_code": "EMAIL_CODE_REQUIRED", + "error_message": "Email and code are required", + } + 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(): + 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) + # Process workspace and project invitations + process_workspace_project_invitations(user=user) + if user.is_password_autoset: + 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 = { + "error_code": str(e.error_code), + "error_message": str(e.error_message), + } + 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 == "": + params = { + "error_code": "EMAIL_CODE_REQUIRED", + "error_message": "Email and code are required", + } + 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(): + params = { + "error_code": "USER_ALREADY_EXIST", + "error_message": "User already exists with the email.", + } + 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 = { + "error_code": str(e.error_code), + "error_message": str(e.error_message), + } + 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..46cd0fa7c --- /dev/null +++ b/apiserver/plane/authentication/views/app/signout.py @@ -0,0 +1,21 @@ +# 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 + +# Module imports +from plane.authentication.utils.host import base_host + + +class SignOutAuthEndpoint(View): + + def post(self, request): + logout(request) + url = urljoin( + base_host(request=request), + "accounts/sign-in?" + urlencode({"success": "true"}), + ) + return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/common.py b/apiserver/plane/authentication/views/common.py new file mode 100644 index 000000000..693054596 --- /dev/null +++ b/apiserver/plane/authentication/views/common.py @@ -0,0 +1,294 @@ +# 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 + + +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: + return Response( + { + "error_code": "INSTANCE_NOT_CONFIGURED", + "error_message": "Instance is not configured", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + (EMAIL_HOST, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD) = ( + get_configuration_value( + [ + { + "key": "EMAIL_HOST", + "default": os.environ.get("EMAIL_HOST"), + }, + { + "key": "EMAIL_HOST_USER", + "default": os.environ.get("EMAIL_HOST_USER"), + }, + { + "key": "EMAIL_HOST_PASSWORD", + "default": os.environ.get("EMAIL_HOST_PASSWORD"), + }, + ] + ) + ) + + if not (EMAIL_HOST and EMAIL_HOST_USER and EMAIL_HOST_PASSWORD): + return Response( + { + "error_code": "SMTP_NOT_CONFIGURED", + "error_message": "SMTP is not configured. Please contact your admin", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + validate_email(email) + except ValidationError: + return Response( + { + "error_code": "INVALID_EMAIL", + "error_message": "Please enter a valid email", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the user + user = User.objects.filter(email=email).first() + if user: + # Get the reset token for user + uidb64, token = generate_password_token(user=user) + current_site = request.META.get("HTTP_ORIGIN") + # send the forgot password email + forgot_password.delay( + user.first_name, user.email, uidb64, token, current_site + ) + return Response( + {"message": "Check your email to reset your password"}, + status=status.HTTP_200_OK, + ) + return Response( + { + "error_code": "INVALID_EMAIL", + "error_message": "Please check the email", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class ResetPasswordEndpoint(View): + + def post(self, request, uidb64, token): + try: + # Decode the id from the uidb64 + id = smart_str(urlsafe_base64_decode(uidb64)) + user = User.objects.get(id=id) + + # check if the token is valid for the user + if not PasswordResetTokenGenerator().check_token(user, token): + url = urljoin( + base_host(request=request), + "accounts/reset-password?" + + urlencode( + { + "error_code": "INVALID_TOKEN", + "error_message": "Token is invalid", + } + ), + ) + return HttpResponseRedirect(url) + + password = request.POST.get("password", False) + + if not password: + url = urljoin( + base_host(request=request), + "?" + urlencode({"error": "Password is required"}), + ) + return HttpResponseRedirect(url) + + # Check the password complexity + results = zxcvbn(password) + if results["score"] < 3: + url = urljoin( + base_host(request=request), + "accounts/reset-password?" + + urlencode( + { + "error_code": "INVALID_PASSWORD", + "error_message": "The password is not a valid password", + } + ), + ) + return HttpResponseRedirect(url) + + # set_password also hashes the password that the user will get + user.set_password(password) + user.is_password_autoset = False + user.save() + + url = urljoin( + base_host(request=request), + "accounts/sign-in?" + urlencode({"success", True}), + ) + return HttpResponseRedirect(url) + except DjangoUnicodeDecodeError: + url = urljoin( + base_host(request=request), + "accounts/reset-password?" + + urlencode( + { + "error_code": "INVALID_TOKEN", + "error_message": "The password token is not valid", + } + ), + ) + return HttpResponseRedirect(url) + + +class ChangePasswordEndpoint(APIView): + def post(self, request): + serializer = ChangePasswordSerializer(data=request.data) + user = User.objects.get(pk=request.user.id) + if serializer.is_valid(): + if not user.check_password(serializer.data.get("old_password")): + return Response( + {"error": "Old password is not correct"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # check the password score + results = zxcvbn(serializer.data.get("new_password")) + if results["score"] < 3: + return Response( + { + "error_code": "INVALID_PASSWORD", + "error_message": "Invalid password.", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # 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, + ) + return Response( + { + "error_code": "INVALID_PASSWORD", + "error_message": "Invalid passwords provided", + }, + 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: + 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_code": "INVALID_PASSWORD", + "error_message": "Invalid password.", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + results = zxcvbn(password) + if results["score"] < 3: + return Response( + { + "error_code": "INVALID_PASSWORD", + "error_message": "Invalid password.", + }, + 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..9f16cc45f --- /dev/null +++ b/apiserver/plane/authentication/views/space/check.py @@ -0,0 +1,48 @@ +# 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 + + +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: + return Response( + { + "error_code": "INSTANCE_NOT_CONFIGURED", + "error_message": "Instance is not configured", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + email = request.data.get("email", False) + + # 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..8849fab7b --- /dev/null +++ b/apiserver/plane/authentication/views/space/email.py @@ -0,0 +1,201 @@ +# 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.adapter.base import AuthenticationException +from plane.authentication.provider.credentials.email import EmailProvider +from plane.authentication.utils.login import user_login +from plane.license.models import Instance +from plane.authentication.utils.host import base_host +from plane.db.models import User + + +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: + params = { + "error_code": "INSTANCE_NOT_CONFIGURED", + "error_message": "Instance is not configured", + } + 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: + params = { + "error_code": "REQUIRED_EMAIL_PASSWORD", + "error_message": "Both email and password are required", + } + 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: + params = { + "error_code": "INVALID_EMAIL", + "error_message": "Please provide a valid email address.", + } + 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), + "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 = { + "error_code": str(e.error_code), + "error_message": str(e.error_message), + } + 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: + params = { + "error_code": "INSTANCE_NOT_CONFIGURED", + "error_message": "Instance is not configured", + } + 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: + params = { + "error_code": "REQUIRED_EMAIL_PASSWORD", + "error_message": "Both email and password are required", + } + 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: + params = { + "error_code": "INVALID_EMAIL", + "error_message": "Please provide a valid email address.", + } + 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(): + params = { + "error_code": "USER_ALREADY_EXIST", + "error_message": "User already exists with the email.", + } + 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 = { + "error_code": str(e.error_code), + "error_message": str(e.error_message), + } + 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..082d1578f --- /dev/null +++ b/apiserver/plane/authentication/views/space/github.py @@ -0,0 +1,118 @@ +# 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 + + +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: + params = { + "error_code": "INSTANCE_NOT_CONFIGURED", + "error_message": "Instance is not configured", + } + 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 ImproperlyConfigured as e: + params = { + "error_code": "IMPROPERLY_CONFIGURED", + "error_message": str(e), + } + 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", ""): + params = { + "error_code": "OAUTH_PROVIDER_ERROR", + "error_message": "State does not match", + } + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host, + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + if not code: + params = { + "error_code": "OAUTH_PROVIDER_ERROR", + "error_message": "Something went wrong while fetching data from OAuth provider. Please try again after sometime.", + } + 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 ImproperlyConfigured as e: + params = { + "error_code": "IMPROPERLY_CONFIGURED", + "error_message": str(e), + } + 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..354d73078 --- /dev/null +++ b/apiserver/plane/authentication/views/space/google.py @@ -0,0 +1,116 @@ +# 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 + + +# Module imports +from plane.license.models import Instance +from plane.authentication.utils.host import base_host + + +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: + params = { + "error_code": "INSTANCE_NOT_CONFIGURED", + "error_message": "Instance is not configured", + } + 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 ImproperlyConfigured as e: + params = { + "error_code": "IMPROPERLY_CONFIGURED", + "error_message": str(e), + } + 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", ""): + params = { + "error_code": "OAUTH_PROVIDER_ERROR", + "error_message": "State does not match", + } + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host, + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + if not code: + params = { + "error_code": "OAUTH_PROVIDER_ERROR", + "error_message": "Something went wrong while fetching data from OAuth provider. Please try again after sometime.", + } + 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 ImproperlyConfigured as e: + params = { + "error_code": "IMPROPERLY_CONFIGURED", + "error_message": str(e), + } + 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..bef7154cf --- /dev/null +++ b/apiserver/plane/authentication/views/space/magic.py @@ -0,0 +1,205 @@ +# 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.adapter.base import AuthenticationException +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 + + +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: + return Response( + { + "error_code": "INSTANCE_NOT_CONFIGURED", + "error_message": "Instance is not configured", + } + ) + + 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 ImproperlyConfigured as e: + return Response( + { + "error_code": "IMPROPERLY_CONFIGURED", + "error_message": str(e), + }, + status=status.HTTP_400_BAD_REQUEST, + ) + except AuthenticationException as e: + return Response( + { + "error_code": str(e.error_code), + "error_message": str(e.error_message), + }, + status=status.HTTP_400_BAD_REQUEST, + ) + except ValidationError: + return Response( + { + "error_code": "INVALID_EMAIL", + "error_message": "Valid email is required for generating a magic code", + }, + 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 == "": + params = { + "error_code": "EMAIL_CODE_REQUIRED", + "error_message": "Email and code are required", + } + 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 + url = urljoin( + base_host(request=request), + str(next_path) if next_path else "spaces", + ) + return HttpResponseRedirect(url) + + except AuthenticationException as e: + params = { + "error_code": str(e.error_code), + "error_message": str(e.error_message), + } + 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 == "": + params = { + "error_code": "EMAIL_CODE_REQUIRED", + "error_message": "Email and code are required", + } + 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(): + params = { + "error_code": "USER_ALREADY_EXIST", + "error_message": "User already exists with the email.", + } + 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 = { + "error_code": str(e.error_code), + "error_message": str(e.error_message), + } + 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..622715ebf --- /dev/null +++ b/apiserver/plane/authentication/views/space/signout.py @@ -0,0 +1,21 @@ +# 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 + +# Module imports +from plane.authentication.utils.host import base_host + + +class SignOutAuthSpaceEndpoint(View): + + def post(self, request): + logout(request) + url = urljoin( + base_host(request=request), + "spaces/accounts/sign-in?" + urlencode({"success": "true"}), + ) + return HttpResponseRedirect(url) 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..9d8cc50be --- /dev/null +++ b/apiserver/plane/db/migrations/0065_auto_20240415_0937.py @@ -0,0 +1,260 @@ +# 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", + ), + ] 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..edebaf132 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} @@ -121,7 +123,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..d74eb6ca2 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(): 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..c9c028f32 --- /dev/null +++ b/apiserver/plane/license/api/views/admin.py @@ -0,0 +1,394 @@ +# 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 + + +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: + url = urljoin( + base_host(request=request), + "god-mode/setup?" + + urlencode( + { + "error_code": "INSTANCE_NOT_CONFIGURED", + "error_message": "Instance is not configured", + } + ), + ) + return HttpResponseRedirect(url) + + # check if the instance has already an admin registered + if InstanceAdmin.objects.first(): + url = urljoin( + base_host(request=request), + "god-mode/setup?" + + urlencode( + { + "error_code": "ADMIN_ALREADY_EXIST", + "error_message": "Admin for the instance has been already registered.", + } + ), + ) + 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: + url = urljoin( + base_host(request=request), + "god-mode/setup?" + + urlencode( + { + "email": email, + "first_name": first_name, + "last_name": last_name, + "company_name": company_name, + "is_telemetry_enabled": is_telemetry_enabled, + "error_code": "REQUIRED_EMAIL_PASSWORD_FIRST_NAME", + "error_message": "Email, name and password are required", + } + ), + ) + return HttpResponseRedirect(url) + + # Validate the email + email = email.strip().lower() + try: + validate_email(email) + except ValidationError: + url = urljoin( + base_host(request=request), + "god-mode/setup?" + + urlencode( + { + "email": email, + "first_name": first_name, + "last_name": last_name, + "company_name": company_name, + "is_telemetry_enabled": is_telemetry_enabled, + "error_code": "INVALID_EMAIL", + "error_message": "Please provide a valid email address.", + } + ), + ) + return HttpResponseRedirect(url) + + # Check if already a user exists or not + # Existing user + if User.objects.filter(email=email).exists(): + url = urljoin( + base_host(request=request), + "god-mode/setup?" + + urlencode( + { + "email": email, + "first_name": first_name, + "last_name": last_name, + "company_name": company_name, + "is_telemetry_enabled": is_telemetry_enabled, + "error_code": "USER_ALREADY_EXISTS", + "error_message": "User already exists.", + } + ), + ) + return HttpResponseRedirect(url) + else: + + results = zxcvbn(password) + if results["score"] < 3: + url = urljoin( + base_host(request=request), + "god-mode/setup?" + + urlencode( + { + "email": email, + "first_name": first_name, + "last_name": last_name, + "company_name": company_name, + "is_telemetry_enabled": is_telemetry_enabled, + "error_code": "INVALID_PASSWORD", + "error_message": "Invalid password provided.", + } + ), + ) + 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: + url = urljoin( + base_host(request=request), + "god-mode/login?" + + urlencode( + { + "error_code": "INSTANCE_NOT_CONFIGURED", + "error_message": "Instance is not configured", + } + ), + ) + 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: + url = urljoin( + base_host(request=request), + "god-mode/login?" + + urlencode( + { + "email": email, + "error_code": "REQUIRED_EMAIL_PASSWORD", + "error_message": "Email and password are required", + } + ), + ) + return HttpResponseRedirect(url) + + # Validate the email + email = email.strip().lower() + try: + validate_email(email) + except ValidationError: + url = urljoin( + base_host(request=request), + "god-mode/login?" + + urlencode( + { + "email": email, + "error_code": "INVALID_EMAIL", + "error_message": "Please provide a valid email address.", + } + ), + ) + return HttpResponseRedirect(url) + + # Fetch the user + user = User.objects.filter(email=email).first() + + # Error out if the user is not present + if not user: + url = urljoin( + base_host(request=request), + "god-mode/login?" + + urlencode( + { + "email": email, + "error_code": "USER_DOES_NOT_EXIST", + "error_message": "User does not exist", + } + ), + ) + return HttpResponseRedirect(url) + + # Check password of the user + if not user.check_password(password): + url = urljoin( + base_host(request=request), + "god-mode/login?" + + urlencode( + { + "email": email, + "error_code": "AUTHENTICATION_FAILED", + "error_message": "Sorry, we could not find an admin user with the provided credentials. Please try again.", + } + ), + ) + return HttpResponseRedirect(url) + + # Check if the user is an instance admin + if not InstanceAdmin.objects.filter(instance=instance, user=user): + url = urljoin( + base_host(request=request), + "god-mode/login?" + + urlencode( + { + "email": email, + "error_code": "AUTHENTICATION_FAILED", + "error_message": "Sorry, we could not find an admin user with the provided credentials. Please try again.", + } + ), + ) + 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): + logout(request) + url = urljoin( + base_host(request=request), + "god-mode/login?" + urlencode({"success": "true"}), + ) + return HttpResponseRedirect(url) 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..40b3c7e0d 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,117 @@ 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) + and bool(EMAIL_HOST_USER) + and bool(EMAIL_HOST_PASSWORD) + ) + 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 +172,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 06c6778d9..908ef446c 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", @@ -109,9 +107,6 @@ TEMPLATES = [ }, ] -# Cookie Settings -SESSION_COOKIE_SECURE = True -CSRF_COOKIE_SECURE = True # CORS Settings CORS_ALLOW_CREDENTIALS = True @@ -122,8 +117,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 +247,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" @@ -349,3 +321,21 @@ 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" + +# 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..e33d580de 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -34,4 +34,4 @@ posthog==3.0.2 cryptography==42.0.4 lxml==4.9.3 boto3==1.28.40 - +zxcvbn==4.4.28 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/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 6efe0e0a1..03e7424df 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..8ddbeed8a 100644 --- a/nginx/nginx.conf.template +++ b/nginx/nginx.conf.template @@ -25,12 +25,15 @@ 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 /${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 534bda24f..af08af608 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..15b4f2018 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; +}; \ No newline at end of file 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..452455876 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,38 +19,63 @@ 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; - theme: IUserTheme; - use_case?: string; + last_login_medium: TLoginMediums; + // theme: IUserTheme; } +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: string | 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; }; } diff --git a/packages/ui/src/avatar/avatar-group.tsx b/packages/ui/src/avatar/avatar-group.tsx index 60fdc917d..501f69490 100644 --- a/packages/ui/src/avatar/avatar-group.tsx +++ b/packages/ui/src/avatar/avatar-group.tsx @@ -1,6 +1,8 @@ import React from "react"; // ui import { Tooltip } from "../tooltip"; +// helpers +import { cn } from "../../helpers"; // types import { TAvatarSize, getSizeInfo, isAValidNumber } from "./avatar"; @@ -55,7 +57,7 @@ export const AvatarGroup: React.FC = (props) => { const sizeInfo = getSizeInfo(size); return ( -
    +
    {avatarsWithUpdatedProps.map((avatar, index) => (
    {avatar} @@ -64,9 +66,12 @@ export const AvatarGroup: React.FC = (props) => { {maxAvatarsToRender < totalAvatars && (
    = (props) => { return (
    = (props) => { tabIndex={-1} > {src ? ( - {name} + {name} ) : (
    { variant?: TBadgeVariant; @@ -31,7 +32,7 @@ const Badge = React.forwardRef((props, ref) => { const buttonIconStyle = getIconStyling(size); return ( - + + ); +}; 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..a9aeec30f --- /dev/null +++ b/space/components/accounts/auth-forms/password.tsx @@ -0,0 +1,213 @@ +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 } 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"; +import { useMobxStore } from "@/lib/mobx/store-provider"; +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); + // hooks + const { + instanceStore: { instance }, + } = useMobxStore(); + // 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( + () => + !!passwordFormData.password && + (mode === EAuthModes.SIGN_UP + ? getPasswordStrength(passwordFormData.password) >= 3 && + passwordFormData.password === passwordFormData.confirm_password + : true) + ? false + : true, + [mode, passwordFormData] + ); + + return ( +
    + + +
    + +
    + 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 && getPasswordStrength(passwordFormData.password) >= 3 && ( +
    + +
    + 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 && ( + Password doesn{"'"}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..1fce06d18 --- /dev/null +++ b/space/components/accounts/auth-forms/root.tsx @@ -0,0 +1,175 @@ +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 useToast from "@/hooks/use-toast"; +import { useMobxStore } from "@/lib/mobx/store-provider"; +// 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]: { + [step in Exclude]: TTitle; + }; +}; + +const Titles: THeaderSubheader = { + [EAuthModes.SIGN_IN]: { + [EAuthSteps.PASSWORD]: { + header: "Sign in to Plane", + subHeader: "Get back to your projects and make progress", + }, + [EAuthSteps.UNIQUE_CODE]: { + header: "Sign in to Plane", + subHeader: "Get back to your projects and make progress", + }, + }, + [EAuthModes.SIGN_UP]: { + [EAuthSteps.PASSWORD]: { + header: "Create your account", + subHeader: "Progress, visualize, and measure work how it works best for you.", + }, + [EAuthSteps.UNIQUE_CODE]: { + header: "Create your account", + subHeader: "Progress, visualize, and measure work how it works best for you.", + }, + }, +}; + +// TODO: Better approach for this. +const getHeaderSubHeader = (mode: EAuthModes | null, step: EAuthSteps): TTitle => { + if (mode) { + return (Titles[mode] as any)[step]; + } + + return { + header: "Get started with Plane", + subHeader: "Progress, visualize, and measure work how it works best for you.", + }; +}; + +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 { + instanceStore: { instance }, + } = useMobxStore(); + // derived values + const isSmtpConfigured = instance?.config?.is_smtp_configured; + + const { header, subHeader } = getHeaderSubHeader(authMode, authStep); + + 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..9bbf392a7 --- /dev/null +++ b/space/components/accounts/auth-forms/unique-code.tsx @@ -0,0 +1,185 @@ +import React, { useEffect, useState } from "react"; +// hooks +// types +import { useRouter } from "next/router"; +// icons +import { CircleCheck, XCircle } from "lucide-react"; +// ui +import { Button, Input } from "@plane/ui"; +// helpers +import { API_BASE_URL } from "@/helpers/common.helper"; +// services +import { AuthService } from "@/services/authentication.service"; +import useTimer from "hooks/use-timer"; +import useToast from "hooks/use-toast"; +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); + // 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; + + return ( +
    + + +
    + +
    + 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 ( - <> -