diff --git a/apiserver/.env.example b/apiserver/.env.example index 8772dfd53..8193b5e77 100644 --- a/apiserver/.env.example +++ b/apiserver/.env.example @@ -60,5 +60,13 @@ DEFAULT_PASSWORD="password123" # SignUps ENABLE_SIGNUP="1" + +# Enable Email/Password Signup +ENABLE_EMAIL_PASSWORD="1" + +# Enable Magic link Login +ENABLE_MAGIC_LINK_LOGIN="0" + # Email redirections and minio domain settings WEB_URL="http://localhost" + diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index c03d6d5b7..f7ad735c1 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -17,6 +17,7 @@ from .project import ( ProjectMemberEndpoint, WorkspaceProjectDeployBoardEndpoint, LeaveProjectEndpoint, + ProjectPublicCoverImagesEndpoint, ) from .user import ( UserEndpoint, @@ -147,16 +148,13 @@ from .page import ( from .search import GlobalSearchEndpoint, IssueSearchEndpoint -from .gpt import GPTIntegrationEndpoint +from .external import GPTIntegrationEndpoint, ReleaseNotesEndpoint, UnsplashEndpoint from .estimate import ( ProjectEstimatePointEndpoint, BulkEstimatePointEndpoint, ) - -from .release import ReleaseNotesEndpoint - from .inbox import InboxViewSet, InboxIssueViewSet, InboxIssuePublicViewSet from .analytic import ( @@ -169,4 +167,6 @@ from .analytic import ( from .notification import NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet -from .exporter import ExportIssuesEndpoint \ No newline at end of file +from .exporter import ExportIssuesEndpoint + +from .config import ConfigurationEndpoint \ No newline at end of file diff --git a/apiserver/plane/api/views/config.py b/apiserver/plane/api/views/config.py new file mode 100644 index 000000000..ea1b39d9c --- /dev/null +++ b/apiserver/plane/api/views/config.py @@ -0,0 +1,40 @@ +# Python imports +import os + +# Django imports +from django.conf import settings + +# Third party imports +from rest_framework.permissions import AllowAny +from rest_framework import status +from rest_framework.response import Response +from sentry_sdk import capture_exception + +# Module imports +from .base import BaseAPIView + + +class ConfigurationEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def get(self, request): + try: + data = {} + data["google"] = os.environ.get("GOOGLE_CLIENT_ID", None) + data["github"] = os.environ.get("GITHUB_CLIENT_ID", None) + data["github_app_name"] = os.environ.get("GITHUB_APP_NAME", None) + data["magic_login"] = ( + bool(settings.EMAIL_HOST_USER) and bool(settings.EMAIL_HOST_PASSWORD) + ) and os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "0") == "1" + data["email_password_login"] = ( + os.environ.get("ENABLE_EMAIL_PASSWORD", "0") == "1" + ) + return Response(data, status=status.HTTP_200_OK) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apiserver/plane/api/views/gpt.py b/apiserver/plane/api/views/external.py similarity index 62% rename from apiserver/plane/api/views/gpt.py rename to apiserver/plane/api/views/external.py index 63c3f4f18..00a0270e4 100644 --- a/apiserver/plane/api/views/gpt.py +++ b/apiserver/plane/api/views/external.py @@ -2,9 +2,10 @@ import requests # Third party imports +import openai from rest_framework.response import Response from rest_framework import status -import openai +from rest_framework.permissions import AllowAny from sentry_sdk import capture_exception # Django imports @@ -15,6 +16,7 @@ from .base import BaseAPIView from plane.api.permissions import ProjectEntityPermission from plane.db.models import Workspace, Project from plane.api.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer +from plane.utils.integrations.github import get_release_notes class GPTIntegrationEndpoint(BaseAPIView): @@ -73,3 +75,44 @@ class GPTIntegrationEndpoint(BaseAPIView): {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, ) + + +class ReleaseNotesEndpoint(BaseAPIView): + def get(self, request): + try: + release_notes = get_release_notes() + return Response(release_notes, status=status.HTTP_200_OK) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class UnsplashEndpoint(BaseAPIView): + + def get(self, request): + try: + query = request.GET.get("query", False) + page = request.GET.get("page", 1) + per_page = request.GET.get("per_page", 20) + + url = ( + f"https://api.unsplash.com/search/photos/?client_id={settings.UNSPLASH_ACCESS_KEY}&query={query}&page=${page}&per_page={per_page}" + if query + else f"https://api.unsplash.com/photos/?client_id={settings.UNSPLASH_ACCESS_KEY}&page={page}&per_page={per_page}" + ) + + headers = { + "Content-Type": "application/json", + } + + resp = requests.get(url=url, headers=headers) + return Response(resp.json(), status=status.HTTP_200_OK) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 7847e3a80..1ba227177 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -1,5 +1,6 @@ # Python imports import jwt +import boto3 from datetime import datetime # Django imports @@ -617,7 +618,8 @@ class ProjectMemberViewSet(BaseViewSet): return Response(status=status.HTTP_204_NO_CONTENT) except ProjectMember.DoesNotExist: return Response( - {"error": "Project Member does not exist"}, status=status.HTTP_400_BAD_REQUEST + {"error": "Project Member does not exist"}, + status=status.HTTP_400_BAD_REQUEST, ) except Exception as e: capture_exception(e) @@ -1209,3 +1211,38 @@ class LeaveProjectEndpoint(BaseAPIView): {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, ) + + +class ProjectPublicCoverImagesEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def get(self, request): + try: + files = [] + s3 = boto3.client( + "s3", + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + ) + params = { + "Bucket": settings.AWS_S3_BUCKET_NAME, + "Prefix": "static/project-cover/", + } + + response = s3.list_objects_v2(**params) + # Extracting file keys from the response + if "Contents" in response: + for content in response["Contents"]: + if not content["Key"].endswith( + "/" + ): # This line ensures we're only getting files, not "sub-folders" + files.append( + f"https://{settings.AWS_S3_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}" + ) + + return Response(files, status=status.HTTP_200_OK) + except Exception as e: + capture_exception(e) + return Response([], status=status.HTTP_200_OK) diff --git a/apiserver/plane/api/views/release.py b/apiserver/plane/api/views/release.py deleted file mode 100644 index de827c896..000000000 --- a/apiserver/plane/api/views/release.py +++ /dev/null @@ -1,21 +0,0 @@ -# Third party imports -from rest_framework.response import Response -from rest_framework import status -from sentry_sdk import capture_exception - -# Module imports -from .base import BaseAPIView -from plane.utils.integrations.github import get_release_notes - - -class ReleaseNotesEndpoint(BaseAPIView): - def get(self, request): - try: - release_notes = get_release_notes() - return Response(release_notes, status=status.HTTP_200_OK) - except Exception as e: - capture_exception(e) - return Response( - {"error": "Something went wrong please try again later"}, - status=status.HTTP_400_BAD_REQUEST, - ) diff --git a/apiserver/plane/settings/local.py b/apiserver/plane/settings/local.py index 9d293c019..6f4833a6c 100644 --- a/apiserver/plane/settings/local.py +++ b/apiserver/plane/settings/local.py @@ -114,3 +114,6 @@ CELERY_BROKER_URL = os.environ.get("REDIS_URL") GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False) ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1" + +# Unsplash Access key +UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY") diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py index 170a2067c..9c6bd95a9 100644 --- a/apiserver/plane/settings/production.py +++ b/apiserver/plane/settings/production.py @@ -275,3 +275,7 @@ ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1" SCOUT_MONITOR = os.environ.get("SCOUT_MONITOR", False) SCOUT_KEY = os.environ.get("SCOUT_KEY", "") SCOUT_NAME = "Plane" + +# Unsplash Access key +UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY") + diff --git a/apiserver/plane/settings/selfhosted.py b/apiserver/plane/settings/selfhosted.py index 948ba22da..ee529a7c3 100644 --- a/apiserver/plane/settings/selfhosted.py +++ b/apiserver/plane/settings/selfhosted.py @@ -126,3 +126,4 @@ ANALYTICS_BASE_API = False OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1") OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False) GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo") + diff --git a/apiserver/plane/settings/staging.py b/apiserver/plane/settings/staging.py index 5e274f8f3..f776afd91 100644 --- a/apiserver/plane/settings/staging.py +++ b/apiserver/plane/settings/staging.py @@ -218,3 +218,7 @@ CELERY_BROKER_URL = broker_url GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False) ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1" + + +# Unsplash Access key +UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY") diff --git a/space/components/accounts/github-login-button.tsx b/space/components/accounts/github-login-button.tsx index e9b30ab73..b1bd586fe 100644 --- a/space/components/accounts/github-login-button.tsx +++ b/space/components/accounts/github-login-button.tsx @@ -10,9 +10,12 @@ import githubWhiteImage from "public/logos/github-white.svg"; export interface GithubLoginButtonProps { handleSignIn: React.Dispatch; + clientId: string; } -export const GithubLoginButton: FC = ({ handleSignIn }) => { +export const GithubLoginButton: FC = (props) => { + const { handleSignIn, clientId } = props; + // states const [loginCallBackURL, setLoginCallBackURL] = useState(undefined); const [gitCode, setGitCode] = useState(null); @@ -38,7 +41,7 @@ export const GithubLoginButton: FC = ({ handleSignIn })
- {parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? ( - <> -

- Sign in to Plane -

-
-
- -
-
- - {/* */} -
+

Sign in to Plane

+ {data?.email_password_login && } + + {data?.magic_login && ( +
+
+
- - ) : ( - +
)} - {parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? ( -

- By signing up, you agree to the{" "} - - Terms & Conditions - -

- ) : null} +
+ {data?.google && } +
+ +

+ By signing up, you agree to the{" "} + + Terms & Conditions + +

diff --git a/space/components/issues/navbar/index.tsx b/space/components/issues/navbar/index.tsx index 35ffbe289..03f082f33 100644 --- a/space/components/issues/navbar/index.tsx +++ b/space/components/issues/navbar/index.tsx @@ -129,7 +129,7 @@ const IssueNavbar = observer(() => { ) : (
- + Sign in diff --git a/space/components/views/login.tsx b/space/components/views/login.tsx index d01a22681..406d6be98 100644 --- a/space/components/views/login.tsx +++ b/space/components/views/login.tsx @@ -7,7 +7,13 @@ import { SignInView, UserLoggedIn } from "components/accounts"; export const LoginView = observer(() => { const { user: userStore } = useMobxStore(); - if (!userStore.currentUser) return ; - - return ; + return ( + <> + {userStore?.loader ? ( +
Loading
+ ) : ( + <>{userStore.currentUser ? : } + )} + + ); }); diff --git a/space/lib/mobx/store-init.tsx b/space/lib/mobx/store-init.tsx index 6e38d9c6d..4fc761ad1 100644 --- a/space/lib/mobx/store-init.tsx +++ b/space/lib/mobx/store-init.tsx @@ -3,12 +3,14 @@ import { useEffect } from "react"; // next imports import { useRouter } from "next/router"; +// js cookie +import Cookie from "js-cookie"; // mobx store import { useMobxStore } from "lib/mobx/store-provider"; import { RootStore } from "store/root"; const MobxStoreInit = () => { - const store: RootStore = useMobxStore(); + const { user: userStore }: RootStore = useMobxStore(); const router = useRouter(); const { states, labels, priorities } = router.query as { states: string[]; labels: string[]; priorities: string[] }; @@ -19,6 +21,11 @@ const MobxStoreInit = () => { // store.issue.userSelectedStates = states || []; // }, [store.issue]); + useEffect(() => { + const authToken = Cookie.get("accessToken") || null; + if (authToken) userStore.fetchCurrentUser(); + }, [userStore]); + return <>; }; diff --git a/space/pages/index.tsx b/space/pages/index.tsx new file mode 100644 index 000000000..1ff239253 --- /dev/null +++ b/space/pages/index.tsx @@ -0,0 +1,19 @@ +import { useEffect } from "react"; + +// next +import { NextPage } from "next"; +import { useRouter } from "next/router"; + +const Index: NextPage = () => { + const router = useRouter(); + const { next_path } = router.query as { next_path: string }; + + useEffect(() => { + if (next_path) router.push(`/login?next_path=${next_path}`); + else router.push(`/login`); + }, [router, next_path]); + + return null; +}; + +export default Index; diff --git a/space/pages/login/index.tsx b/space/pages/login/index.tsx index a80eff873..9f20f099f 100644 --- a/space/pages/login/index.tsx +++ b/space/pages/login/index.tsx @@ -5,4 +5,4 @@ import { LoginView } from "components/views"; const LoginPage = () => ; -export default LoginPage; \ No newline at end of file +export default LoginPage; diff --git a/space/services/app-config.service.ts b/space/services/app-config.service.ts new file mode 100644 index 000000000..713cda3da --- /dev/null +++ b/space/services/app-config.service.ts @@ -0,0 +1,30 @@ +// services +import APIService from "services/api.service"; +// helper +import { API_BASE_URL } from "helpers/common.helper"; + +export interface IEnvConfig { + github: string; + google: string; + github_app_name: string | null; + email_password_login: boolean; + magic_login: boolean; +} + +export class AppConfigService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async envConfig(): Promise { + return this.get("/api/configs/", { + headers: { + "Content-Type": "application/json", + }, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/space/services/file.service.ts b/space/services/file.service.ts index d9783d29c..6df6423f4 100644 --- a/space/services/file.service.ts +++ b/space/services/file.service.ts @@ -74,24 +74,6 @@ class FileServices extends APIService { throw error?.response?.data; }); } - - async getUnsplashImages(page: number = 1, query?: string): Promise { - const url = "/api/unsplash"; - - return this.request({ - method: "get", - url, - params: { - page, - per_page: 20, - query, - }, - }) - .then((response) => response?.data?.results ?? response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } } const fileServices = new FileServices(); diff --git a/space/store/user.ts b/space/store/user.ts index 3a76c2111..cec2d340f 100644 --- a/space/store/user.ts +++ b/space/store/user.ts @@ -7,12 +7,17 @@ import { ActorDetail } from "types/issue"; import { IUser } from "types/user"; export interface IUserStore { + loader: boolean; + error: any | null; currentUser: any | null; fetchCurrentUser: () => void; currentActor: () => any; } class UserStore implements IUserStore { + loader: boolean = false; + error: any | null = null; + currentUser: IUser | null = null; // root store rootStore; @@ -22,6 +27,9 @@ class UserStore implements IUserStore { constructor(_rootStore: any) { makeObservable(this, { // observable + loader: observable.ref, + error: observable.ref, + currentUser: observable.ref, // actions setCurrentUser: action, @@ -73,14 +81,19 @@ class UserStore implements IUserStore { fetchCurrentUser = async () => { try { + this.loader = true; + this.error = null; const response = await this.userService.currentUser(); if (response) { runInAction(() => { + this.loader = false; this.currentUser = response; }); } } catch (error) { console.error("Failed to fetch current user", error); + this.loader = false; + this.error = error; } }; } diff --git a/turbo.json b/turbo.json index 59bbe741f..e40a56ab7 100644 --- a/turbo.json +++ b/turbo.json @@ -1,8 +1,6 @@ { "$schema": "https://turbo.build/schema.json", "globalEnv": [ - "NEXT_PUBLIC_GITHUB_ID", - "NEXT_PUBLIC_GOOGLE_CLIENTID", "NEXT_PUBLIC_API_BASE_URL", "NEXT_PUBLIC_DEPLOY_URL", "API_BASE_URL", @@ -12,8 +10,6 @@ "NEXT_PUBLIC_GITHUB_APP_NAME", "NEXT_PUBLIC_ENABLE_SENTRY", "NEXT_PUBLIC_ENABLE_OAUTH", - "NEXT_PUBLIC_UNSPLASH_ACCESS", - "NEXT_PUBLIC_UNSPLASH_ENABLED", "NEXT_PUBLIC_TRACK_EVENTS", "NEXT_PUBLIC_PLAUSIBLE_DOMAIN", "NEXT_PUBLIC_CRISP_ID", diff --git a/web/components/account/email-password-form.tsx b/web/components/account/email-password-form.tsx index bb341b371..7a95095ee 100644 --- a/web/components/account/email-password-form.tsx +++ b/web/components/account/email-password-form.tsx @@ -1,12 +1,5 @@ -import React, { useState } from "react"; - -import { useRouter } from "next/router"; -import Link from "next/link"; - -// react hook form +import React from "react"; import { useForm } from "react-hook-form"; -// components -import { EmailResetPasswordForm } from "components/account"; // ui import { Input, PrimaryButton } from "components/ui"; // types @@ -18,14 +11,12 @@ type EmailPasswordFormValues = { type Props = { onSubmit: (formData: EmailPasswordFormValues) => Promise; + setIsResettingPassword: (value: boolean) => void; }; -export const EmailPasswordForm: React.FC = ({ onSubmit }) => { - const [isResettingPassword, setIsResettingPassword] = useState(false); - - const router = useRouter(); - const isSignUpPage = router.pathname === "/sign-up"; - +export const EmailPasswordForm: React.FC = (props) => { + const { onSubmit, setIsResettingPassword } = props; + // form info const { register, handleSubmit, @@ -42,94 +33,62 @@ export const EmailPasswordForm: React.FC = ({ onSubmit }) => { return ( <> -

- {isResettingPassword - ? "Reset your password" - : isSignUpPage - ? "Sign up on Plane" - : "Sign in to Plane"} -

- {isResettingPassword ? ( - - ) : ( -
-
- - /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( - value - ) || "Email address is not valid", - }} - error={errors.email} - placeholder="Enter your email address..." - className="border-custom-border-300 h-[46px]" - /> -
-
- -
-
- {isSignUpPage ? ( - - - Already have an account? Sign in. - - - ) : ( - - )} -
-
- - {isSignUpPage - ? isSubmitting - ? "Signing up..." - : "Sign up" - : isSubmitting - ? "Signing in..." - : "Sign in"} - - {!isSignUpPage && ( - - - Don{"'"}t have an account? Sign up. - - - )} -
-
- )} +
+
+ + /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( + value + ) || "Email address is not valid", + }} + error={errors.email} + placeholder="Enter your email address..." + className="border-custom-border-300 h-[46px]" + /> +
+
+ +
+
+ +
+
+ + {isSubmitting ? "Signing in..." : "Sign in"} + +
+
); }; diff --git a/web/components/account/email-signup-form.tsx b/web/components/account/email-signup-form.tsx new file mode 100644 index 000000000..0a219741f --- /dev/null +++ b/web/components/account/email-signup-form.tsx @@ -0,0 +1,114 @@ +import React from "react"; +import Link from "next/link"; +import { useForm } from "react-hook-form"; +// ui +import { Input, PrimaryButton } from "components/ui"; +// types +type EmailPasswordFormValues = { + email: string; + password?: string; + confirm_password: string; + medium?: string; +}; + +type Props = { + onSubmit: (formData: EmailPasswordFormValues) => Promise; +}; + +export const EmailSignUpForm: React.FC = (props) => { + const { onSubmit } = props; + + const { + register, + handleSubmit, + watch, + formState: { errors, isSubmitting, isValid, isDirty }, + } = useForm({ + defaultValues: { + email: "", + password: "", + confirm_password: "", + medium: "email", + }, + mode: "onChange", + reValidateMode: "onChange", + }); + + return ( + <> +
+
+ + /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( + value + ) || "Email address is not valid", + }} + error={errors.email} + placeholder="Enter your email address..." + className="border-custom-border-300 h-[46px]" + /> +
+
+ +
+
+ { + if (watch("password") != val) { + return "Your passwords do no match"; + } + }, + }} + error={errors.confirm_password} + placeholder="Confirm your password..." + className="border-custom-border-300 h-[46px]" + /> +
+ +
+ + {isSubmitting ? "Signing up..." : "Sign up"} + +
+
+ + ); +}; diff --git a/web/components/account/github-login-button.tsx b/web/components/account/github-login-button.tsx index 2f4fcbc4d..9ea5b7df2 100644 --- a/web/components/account/github-login-button.tsx +++ b/web/components/account/github-login-button.tsx @@ -1,29 +1,27 @@ import { useEffect, useState, FC } from "react"; - import Link from "next/link"; import Image from "next/image"; import { useRouter } from "next/router"; - -// next-themes import { useTheme } from "next-themes"; // images import githubBlackImage from "/public/logos/github-black.png"; import githubWhiteImage from "/public/logos/github-white.png"; -const { NEXT_PUBLIC_GITHUB_ID } = process.env; - export interface GithubLoginButtonProps { handleSignIn: React.Dispatch; + clientId: string; } -export const GithubLoginButton: FC = ({ handleSignIn }) => { +export const GithubLoginButton: FC = (props) => { + const { handleSignIn, clientId } = props; + // states const [loginCallBackURL, setLoginCallBackURL] = useState(undefined); const [gitCode, setGitCode] = useState(null); - + // router const { query: { code }, } = useRouter(); - + // theme const { theme } = useTheme(); useEffect(() => { @@ -42,7 +40,7 @@ export const GithubLoginButton: FC = ({ handleSignIn }) return (
@@ -70,6 +76,7 @@ export const AutoArchiveAutomation: React.FC = ({ projectDetails, handleC input verticalPosition="bottom" width="w-full" + disabled={disabled} > <> {PROJECT_AUTOMATION_MONTHS.map((month) => ( diff --git a/web/components/automation/auto-close-automation.tsx b/web/components/automation/auto-close-automation.tsx index 868d64557..063501036 100644 --- a/web/components/automation/auto-close-automation.tsx +++ b/web/components/automation/auto-close-automation.tsx @@ -24,9 +24,14 @@ import { getStatesList } from "helpers/state.helper"; type Props = { projectDetails: IProject | undefined; handleChange: (formData: Partial) => Promise; + disabled?: boolean; }; -export const AutoCloseAutomation: React.FC = ({ projectDetails, handleChange }) => { +export const AutoCloseAutomation: React.FC = ({ + projectDetails, + handleChange, + disabled = false, +}) => { const [monthModal, setmonthModal] = useState(false); const router = useRouter(); @@ -98,6 +103,7 @@ export const AutoCloseAutomation: React.FC = ({ projectDetails, handleCha : handleChange({ close_in: 0, default_state: null }) } size="sm" + disabled={disabled} />
@@ -119,6 +125,7 @@ export const AutoCloseAutomation: React.FC = ({ projectDetails, handleCha }} input width="w-full" + disabled={disabled} > <> {PROJECT_AUTOMATION_MONTHS.map((month) => ( diff --git a/web/components/core/image-picker-popover.tsx b/web/components/core/image-picker-popover.tsx index 957f1131c..cfe18cd97 100644 --- a/web/components/core/image-picker-popover.tsx +++ b/web/components/core/image-picker-popover.tsx @@ -1,32 +1,23 @@ import React, { useEffect, useState, useRef, useCallback } from "react"; - -// next import Image from "next/image"; import { useRouter } from "next/router"; - -// swr import useSWR from "swr"; - -// react-dropdown import { useDropzone } from "react-dropzone"; - -// headless ui import { Tab, Transition, Popover } from "@headlessui/react"; // services import fileService from "services/file.service"; - -// components -import { Input, Spinner, PrimaryButton, SecondaryButton } from "components/ui"; // hooks import useWorkspaceDetails from "hooks/use-workspace-details"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; - -const unsplashEnabled = - process.env.NEXT_PUBLIC_UNSPLASH_ENABLED === "true" || - process.env.NEXT_PUBLIC_UNSPLASH_ENABLED === "1"; +// components +import { Input, PrimaryButton, SecondaryButton, Loader } from "components/ui"; const tabOptions = [ + { + key: "unsplash", + title: "Unsplash", + }, { key: "images", title: "Images", @@ -64,8 +55,22 @@ export const ImagePickerPopover: React.FC = ({ search: "", }); - const { data: images } = useSWR(`UNSPLASH_IMAGES_${searchParams}`, () => - fileService.getUnsplashImages(1, searchParams) + const { data: unsplashImages, error: unsplashError } = useSWR( + `UNSPLASH_IMAGES_${searchParams}`, + () => fileService.getUnsplashImages(searchParams), + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + } + ); + + const { data: projectCoverImages } = useSWR( + `PROJECT_COVER_IMAGES`, + () => fileService.getProjectCoverImages(), + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + } ); const imagePickerRef = useRef(null); @@ -115,18 +120,17 @@ export const ImagePickerPopover: React.FC = ({ }; useEffect(() => { - if (!images || value !== null) return; - onChange(images[0].urls.regular); - }, [value, onChange, images]); + if (!unsplashImages || value !== null) return; + + onChange(unsplashImages[0].urls.regular); + }, [value, onChange, unsplashImages]); useOutsideClickDetector(imagePickerRef, () => setIsOpen(false)); - if (!unsplashEnabled) return null; - return ( setIsOpen((prev) => !prev)} disabled={disabled} > @@ -141,15 +145,19 @@ export const ImagePickerPopover: React.FC = ({ leaveFrom="transform opacity-100 scale-100" leaveTo="transform opacity-0 scale-95" > - +
-
- - {tabOptions.map((tab) => ( + + {tabOptions.map((tab) => { + if (!unsplashImages && unsplashError && tab.key === "unsplash") return null; + if (projectCoverImages && projectCoverImages.length === 0 && tab.key === "images") + return null; + + return ( @@ -160,50 +168,106 @@ export const ImagePickerPopover: React.FC = ({ > {tab.title} - ))} - -
+ ); + })} + - -
- setFormData({ ...formData, search: e.target.value })} - placeholder="Search for images" - /> - setSearchParams(formData.search)} size="sm"> - Search - -
- {images ? ( -
- {images.map((image) => ( -
- {image.alt_description} { - setIsOpen(false); - onChange(image.urls.regular); - }} - /> + {(unsplashImages || !unsplashError) && ( + +
+ setFormData({ ...formData, search: e.target.value })} + placeholder="Search for images" + /> + setSearchParams(formData.search)} size="sm"> + Search + +
+ {unsplashImages ? ( + unsplashImages.length > 0 ? ( +
+ {unsplashImages.map((image) => ( +
{ + setIsOpen(false); + onChange(image.urls.regular); + }} + > + {image.alt_description} +
+ ))}
- ))} -
- ) : ( -
- -
- )} - - + ) : ( +

+ No images found. +

+ ) + ) : ( + + + + + + + + + + + )} +
+ )} + {(!projectCoverImages || projectCoverImages.length !== 0) && ( + + {projectCoverImages ? ( + projectCoverImages.length > 0 ? ( +
+ {projectCoverImages.map((image, index) => ( +
{ + setIsOpen(false); + onChange(image); + }} + > + {`Default +
+ ))} +
+ ) : ( +

+ No images found. +

+ ) + ) : ( + + + + + + + + + + + )} +
+ )} +
= ({ height={height} width={width} className={className} - viewBox="0 0 12 12" - fill="none" xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 323.15 323.03" > - + + + + + + + + ); diff --git a/web/components/icons/state/started.tsx b/web/components/icons/state/started.tsx index 7bc39f9f7..f4796548b 100644 --- a/web/components/icons/state/started.tsx +++ b/web/components/icons/state/started.tsx @@ -9,17 +9,38 @@ export const StateGroupStartedIcon: React.FC = ({ width = "20", height = "20", className, - color = "#f59e0b", + color = "#f39e1f", }) => ( - - + + + + + + + + + ); diff --git a/web/components/project/create-project-modal.tsx b/web/components/project/create-project-modal.tsx index b6a2be8bb..5593d8c7c 100644 --- a/web/components/project/create-project-modal.tsx +++ b/web/components/project/create-project-modal.tsx @@ -393,7 +393,7 @@ export const CreateProjectModal: React.FC = ({ value={value} onChange={onChange} options={options} - buttonClassName="!px-2 shadow-md" + buttonClassName="border-[0.5px] !px-2 shadow-md" label={
{value ? ( diff --git a/web/components/project/member-select.tsx b/web/components/project/member-select.tsx index 64baa945d..4fcb04268 100644 --- a/web/components/project/member-select.tsx +++ b/web/components/project/member-select.tsx @@ -16,9 +16,10 @@ import { PROJECT_MEMBERS } from "constants/fetch-keys"; type Props = { value: any; onChange: (val: string) => void; + isDisabled?: boolean; }; -export const MemberSelect: React.FC = ({ value, onChange }) => { +export const MemberSelect: React.FC = ({ value, onChange, isDisabled = false }) => { const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -79,6 +80,7 @@ export const MemberSelect: React.FC = ({ value, onChange }) => { position="right" width="w-full" onChange={onChange} + disabled={isDisabled} /> ); }; diff --git a/web/components/ui/dropdowns/custom-menu.tsx b/web/components/ui/dropdowns/custom-menu.tsx index 41450b2b3..f456804f0 100644 --- a/web/components/ui/dropdowns/custom-menu.tsx +++ b/web/components/ui/dropdowns/custom-menu.tsx @@ -46,6 +46,7 @@ const CustomMenu = ({ type="button" onClick={menuButtonOnClick} className={customButtonClassName} + disabled={disabled} > {customButton} diff --git a/web/components/ui/empty-state.tsx b/web/components/ui/empty-state.tsx index 098c3f152..e39b10801 100644 --- a/web/components/ui/empty-state.tsx +++ b/web/components/ui/empty-state.tsx @@ -16,6 +16,7 @@ type Props = { }; secondaryButton?: React.ReactNode; isFullScreen?: boolean; + disabled?: boolean; }; export const EmptyState: React.FC = ({ @@ -25,6 +26,7 @@ export const EmptyState: React.FC = ({ primaryButton, secondaryButton, isFullScreen = true, + disabled = false, }) => (
= ({ {description &&

{description}

}
{primaryButton && ( - + {primaryButton.icon} {primaryButton.text} diff --git a/web/components/ui/toggle-switch.tsx b/web/components/ui/toggle-switch.tsx index d6c512ad7..5ad9377de 100644 --- a/web/components/ui/toggle-switch.tsx +++ b/web/components/ui/toggle-switch.tsx @@ -21,7 +21,7 @@ export const ToggleSwitch: React.FC = (props) => { size === "sm" ? "h-4 w-6" : size === "md" ? "h-5 w-8" : "h-6 w-10" } flex-shrink-0 cursor-pointer rounded-full border border-custom-border-200 transition-colors duration-200 ease-in-out focus:outline-none ${ value ? "bg-custom-primary-100" : "bg-gray-700" - } ${className || ""}`} + } ${className || ""} ${disabled ? "cursor-not-allowed" : ""}`} > {label} = (props) => { ? "translate-x-4" : "translate-x-5") + " bg-white" : "translate-x-0.5 bg-custom-background-90" - }`} + } ${disabled ? "cursor-not-allowed" : ""}`} /> ); diff --git a/web/next.config.js b/web/next.config.js index 1d466aaf5..058a68b7d 100644 --- a/web/next.config.js +++ b/web/next.config.js @@ -15,6 +15,7 @@ const nextConfig = { "vinci-web.s3.amazonaws.com", "planefs-staging.s3.ap-south-1.amazonaws.com", "planefs.s3.amazonaws.com", + "planefs-staging.s3.amazonaws.com", "images.unsplash.com", "avatars.githubusercontent.com", "localhost", diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/settings/automations.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/settings/automations.tsx index 5dcea0838..1f74a74c3 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/settings/automations.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/settings/automations.tsx @@ -2,7 +2,7 @@ import React from "react"; import { useRouter } from "next/router"; -import { mutate } from "swr"; +import useSWR, { mutate } from "swr"; // services import projectService from "services/project.service"; @@ -21,7 +21,7 @@ import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import type { NextPage } from "next"; import { IProject } from "types"; // constant -import { PROJECTS_LIST, PROJECT_DETAILS } from "constants/fetch-keys"; +import { PROJECTS_LIST, PROJECT_DETAILS, USER_PROJECT_VIEW } from "constants/fetch-keys"; // helper import { truncateText } from "helpers/string.helper"; @@ -34,6 +34,13 @@ const AutomationsSettings: NextPage = () => { const { projectDetails } = useProjectDetails(); + const { data: memberDetails } = useSWR( + workspaceSlug && projectId ? USER_PROJECT_VIEW(projectId.toString()) : null, + workspaceSlug && projectId + ? () => projectService.projectMemberMe(workspaceSlug.toString(), projectId.toString()) + : null + ); + const handleChange = async (formData: Partial) => { if (!workspaceSlug || !projectId || !projectDetails) return; @@ -62,6 +69,8 @@ const AutomationsSettings: NextPage = () => { }); }; + const isAdmin = memberDetails?.role === 20; + return ( {
-
+

Automations

- - + +
diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/settings/features.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/settings/features.tsx index 499aaea86..ba74cf2a5 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/settings/features.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/settings/features.tsx @@ -25,7 +25,7 @@ import { ContrastOutlined } from "@mui/icons-material"; import { IProject } from "types"; import type { NextPage } from "next"; // fetch-keys -import { PROJECTS_LIST, PROJECT_DETAILS } from "constants/fetch-keys"; +import { PROJECTS_LIST, PROJECT_DETAILS, USER_PROJECT_VIEW } from "constants/fetch-keys"; // helper import { truncateText } from "helpers/string.helper"; @@ -102,6 +102,13 @@ const FeaturesSettings: NextPage = () => { : null ); + const { data: memberDetails } = useSWR( + workspaceSlug && projectId ? USER_PROJECT_VIEW(projectId.toString()) : null, + workspaceSlug && projectId + ? () => projectService.projectMemberMe(workspaceSlug.toString(), projectId.toString()) + : null + ); + const handleSubmit = async (formData: Partial) => { if (!workspaceSlug || !projectId || !projectDetails) return; @@ -140,6 +147,8 @@ const FeaturesSettings: NextPage = () => { ); }; + const isAdmin = memberDetails?.role === 20; + return ( {
-
+

Features

@@ -199,6 +208,7 @@ const FeaturesSettings: NextPage = () => { [feature.property]: !projectDetails?.[feature.property as keyof IProject], }); }} + disabled={!isAdmin} size="sm" />
diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/settings/integrations.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/settings/integrations.tsx index ca64b8e22..181c9f8ec 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/settings/integrations.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/settings/integrations.tsx @@ -22,7 +22,7 @@ import emptyIntegration from "public/empty-state/integration.svg"; import { IProject } from "types"; import type { NextPage } from "next"; // fetch-keys -import { PROJECT_DETAILS, WORKSPACE_INTEGRATIONS } from "constants/fetch-keys"; +import { PROJECT_DETAILS, USER_PROJECT_VIEW, WORKSPACE_INTEGRATIONS } from "constants/fetch-keys"; // helper import { truncateText } from "helpers/string.helper"; @@ -45,6 +45,15 @@ const ProjectIntegrations: NextPage = () => { : null ); + const { data: memberDetails } = useSWR( + workspaceSlug && projectId ? USER_PROJECT_VIEW(projectId.toString()) : null, + workspaceSlug && projectId + ? () => projectService.projectMemberMe(workspaceSlug.toString(), projectId.toString()) + : null + ); + + const isAdmin = memberDetails?.role === 20; + return ( {
-
+

Integrations

@@ -85,6 +94,7 @@ const ProjectIntegrations: NextPage = () => { text: "Configure now", onClick: () => router.push(`/${workspaceSlug}/settings/integrations`), }} + disabled={!isAdmin} /> ) ) : ( diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/settings/members.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/settings/members.tsx index 59e218ee4..51143d868 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/settings/members.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/settings/members.tsx @@ -43,6 +43,7 @@ import { PROJECT_INVITATIONS_WITH_EMAIL, PROJECT_MEMBERS, PROJECT_MEMBERS_WITH_EMAIL, + USER_PROJECT_VIEW, WORKSPACE_DETAILS, } from "constants/fetch-keys"; // constants @@ -111,6 +112,13 @@ const MembersSettings: NextPage = () => { : null ); + const { data: memberDetails } = useSWR( + workspaceSlug && projectId ? USER_PROJECT_VIEW(projectId.toString()) : null, + workspaceSlug && projectId + ? () => projectService.projectMemberMe(workspaceSlug.toString(), projectId.toString()) + : null + ); + const members = [ ...(projectMembers?.map((item) => ({ id: item.id, @@ -212,6 +220,8 @@ const MembersSettings: NextPage = () => { }); }; + const isAdmin = memberDetails?.role === 20; + return ( {
-
+

Defaults

@@ -296,6 +306,7 @@ const MembersSettings: NextPage = () => { onChange={(val: string) => { submitChanges({ project_lead: val }); }} + isDisabled={!isAdmin} /> )} /> @@ -320,6 +331,7 @@ const MembersSettings: NextPage = () => { onChange={(val: string) => { submitChanges({ default_assignee: val }); }} + isDisabled={!isAdmin} /> )} /> @@ -467,7 +479,7 @@ const MembersSettings: NextPage = () => { ); })} - + { if (member.member) setSelectedRemoveMember(member.id); diff --git a/web/pages/index.tsx b/web/pages/index.tsx index dec63f9f4..cccd32407 100644 --- a/web/pages/index.tsx +++ b/web/pages/index.tsx @@ -1,13 +1,14 @@ -import React, { useEffect } from "react"; - +import React, { useEffect, useState } from "react"; import Image from "next/image"; - import type { NextPage } from "next"; - +import { useTheme } from "next-themes"; +import useSWR from "swr"; +import { observer } from "mobx-react-lite"; // layouts import DefaultLayout from "layouts/default-layout"; // services import authenticationService from "services/authentication.service"; +import { AppConfigService } from "services/app-config.service"; // hooks import useUserAuth from "hooks/use-user-auth"; import useToast from "hooks/use-toast"; @@ -17,19 +18,19 @@ import { GithubLoginButton, EmailCodeForm, EmailPasswordForm, + EmailResetPasswordForm, } from "components/account"; // ui import { Spinner } from "components/ui"; // images import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png"; -// mobx react lite -import { observer } from "mobx-react-lite"; // mobx store import { useMobxStore } from "lib/mobx/store-provider"; -// next themes -import { useTheme } from "next-themes"; +// types import { IUser } from "types"; +const appConfig = new AppConfigService(); + // types type EmailPasswordFormValues = { email: string; @@ -39,11 +40,16 @@ type EmailPasswordFormValues = { const HomePage: NextPage = observer(() => { const store: any = useMobxStore(); + // theme const { setTheme } = useTheme(); - + // user const { isLoading, mutateUser } = useUserAuth("sign-in"); - + // states + const [isResettingPassword, setIsResettingPassword] = useState(false); + // toast const { setToastAlert } = useToast(); + // fetch app config + const { data } = useSWR("APP_CONFIG", () => appConfig.envConfig()); const handleTheme = (user: IUser) => { const currentTheme = user.theme.theme ?? "system"; @@ -79,11 +85,11 @@ const HomePage: NextPage = observer(() => { const handleGitHubSignIn = async (credential: string) => { try { - if (process.env.NEXT_PUBLIC_GITHUB_ID && credential) { + if (data && data.github && credential) { const socialAuthPayload = { medium: "github", credential, - clientId: process.env.NEXT_PUBLIC_GITHUB_ID, + clientId: data.github, }; const response = await authenticationService.socialAuth(socialAuthPayload); if (response && response?.user) { @@ -149,10 +155,6 @@ const HomePage: NextPage = observer(() => { } }; - useEffect(() => { - setTheme("system"); - }, [setTheme]); - return ( {isLoading ? ( @@ -173,38 +175,54 @@ const HomePage: NextPage = observer(() => {
- {parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? ( +

+ {isResettingPassword ? "Reset your password" : "Sign in to Plane"} +

+ {isResettingPassword ? ( + + ) : ( <> -

- Sign in to Plane -

-
-
- -
-
- - + {data?.email_password_login && ( + + )} + {data?.magic_login && ( +
+
+ +
+ )} +
+ {data?.google && ( + + )} + {data?.github && ( + + )}
- ) : ( - )} - {parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? ( -

- By signing up, you agree to the{" "} - - Terms & Conditions - -

- ) : null} +

+ By signing up, you agree to the{" "} + + Terms & Conditions + +

diff --git a/web/pages/sign-up.tsx b/web/pages/sign-up.tsx index 72a391ea4..d2d33032d 100644 --- a/web/pages/sign-up.tsx +++ b/web/pages/sign-up.tsx @@ -1,8 +1,6 @@ -import React, { useEffect, useState } from "react"; - +import React, { useEffect } from "react"; import Image from "next/image"; import { useRouter } from "next/router"; - // next-themes import { useTheme } from "next-themes"; // services @@ -13,9 +11,7 @@ import useToast from "hooks/use-toast"; // layouts import DefaultLayout from "layouts/default-layout"; // components -import { EmailPasswordForm } from "components/account"; -// ui -import { Spinner } from "components/ui"; +import { EmailPasswordForm, EmailSignUpForm } from "components/account"; // images import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png"; // types @@ -27,8 +23,6 @@ type EmailPasswordFormValues = { }; const SignUp: NextPage = () => { - const [isLoading, setIsLoading] = useState(true); - const router = useRouter(); const { setToastAlert } = useToast(); @@ -70,18 +64,6 @@ const SignUp: NextPage = () => { setTheme("system"); }, [setTheme]); - useEffect(() => { - if (parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0")) router.push("/"); - else setIsLoading(false); - }, [router]); - - if (isLoading) - return ( -
- -
- ); - return ( <> @@ -96,7 +78,8 @@ const SignUp: NextPage = () => {
- +

SignUp on Plane

+
diff --git a/web/services/app-config.service.ts b/web/services/app-config.service.ts new file mode 100644 index 000000000..713cda3da --- /dev/null +++ b/web/services/app-config.service.ts @@ -0,0 +1,30 @@ +// services +import APIService from "services/api.service"; +// helper +import { API_BASE_URL } from "helpers/common.helper"; + +export interface IEnvConfig { + github: string; + google: string; + github_app_name: string | null; + email_password_login: boolean; + magic_login: boolean; +} + +export class AppConfigService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async envConfig(): Promise { + return this.get("/api/configs/", { + headers: { + "Content-Type": "application/json", + }, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/web/services/file.service.ts b/web/services/file.service.ts index cbed73fc8..15cdc1786 100644 --- a/web/services/file.service.ts +++ b/web/services/file.service.ts @@ -76,21 +76,23 @@ class FileServices extends APIService { }); } - async getUnsplashImages(page: number = 1, query?: string): Promise { - const url = "/api/unsplash"; - - return this.request({ - method: "get", - url, + async getUnsplashImages(query?: string): Promise { + return this.get(`/api/unsplash/`, { params: { - page, - per_page: 20, query, }, }) - .then((response) => response?.data?.results ?? response?.data) - .catch((error) => { - throw error?.response?.data; + .then((res) => res?.data?.results ?? res?.data) + .catch((err) => { + throw err?.response?.data; + }); + } + + async getProjectCoverImages(): Promise { + return this.get(`/api/project-covers/`) + .then((res) => res?.data) + .catch((err) => { + throw err?.response?.data; }); } }