+
+
-
- {user?.email}
+
+
+
+ {user?.avatar && (
+
+ )}
+
+ {user?.first_name ? `${user?.first_name} ${user?.last_name ?? ""}` : user?.email}
+
+
+
-
-
+
+
+
Welcome to Plane!
+
+ Let’s setup your profile, tell us a bit about yourself.
+
+
+
+
+
+
+
+
+ {user?.avatar && (
+
+ )}
+
+ {user?.first_name ? `${user?.first_name} ${user?.last_name ?? ""}` : user?.email}
+
+
+
+
+
diff --git a/space/public/instance/plane-instance-not-ready.webp b/space/public/instance/plane-instance-not-ready.webp
new file mode 100644
index 000000000..a0efca52c
Binary files /dev/null and b/space/public/instance/plane-instance-not-ready.webp differ
diff --git a/space/public/logos/github-black.png b/space/public/logos/github-black.png
new file mode 100644
index 000000000..7a7a82474
Binary files /dev/null and b/space/public/logos/github-black.png differ
diff --git a/space/public/logos/github-black.svg b/space/public/logos/github-black.svg
deleted file mode 100644
index ad04a798e..000000000
--- a/space/public/logos/github-black.svg
+++ /dev/null
@@ -1,29 +0,0 @@
-
-
-
diff --git a/space/public/logos/github-dark.svg b/space/public/logos/github-dark.svg
new file mode 100644
index 000000000..a0cb35c3a
--- /dev/null
+++ b/space/public/logos/github-dark.svg
@@ -0,0 +1,3 @@
+
diff --git a/space/public/logos/google-logo.svg b/space/public/logos/google-logo.svg
new file mode 100644
index 000000000..088288fa3
--- /dev/null
+++ b/space/public/logos/google-logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/space/public/onboarding/background-pattern-dark.svg b/space/public/onboarding/background-pattern-dark.svg
new file mode 100644
index 000000000..c258cbabf
--- /dev/null
+++ b/space/public/onboarding/background-pattern-dark.svg
@@ -0,0 +1,68 @@
+
diff --git a/space/public/onboarding/background-pattern.svg b/space/public/onboarding/background-pattern.svg
new file mode 100644
index 000000000..5fcbeec27
--- /dev/null
+++ b/space/public/onboarding/background-pattern.svg
@@ -0,0 +1,68 @@
+
diff --git a/space/public/onboarding/profile-setup-dark.svg b/space/public/onboarding/profile-setup-dark.svg
new file mode 100644
index 000000000..ca6f07227
--- /dev/null
+++ b/space/public/onboarding/profile-setup-dark.svg
@@ -0,0 +1,220 @@
+
diff --git a/space/public/onboarding/profile-setup.svg b/space/public/onboarding/profile-setup.svg
new file mode 100644
index 000000000..3364031fb
--- /dev/null
+++ b/space/public/onboarding/profile-setup.svg
@@ -0,0 +1,222 @@
+
diff --git a/space/services/api.service.ts b/space/services/api.service.ts
index d3ad3949b..212a29b53 100644
--- a/space/services/api.service.ts
+++ b/space/services/api.service.ts
@@ -1,38 +1,44 @@
-// axios
import axios from "axios";
-// js cookie
import Cookies from "js-cookie";
abstract class APIService {
protected baseURL: string;
protected headers: any = {};
- constructor(_baseURL: string) {
- this.baseURL = _baseURL;
+ constructor(baseURL: string) {
+ this.baseURL = baseURL;
+ }
+
+ setCSRFToken(token: string) {
+ Cookies.set("csrf_token", token, { expires: 30 });
+ }
+
+ getCSRFToken() {
+ return Cookies.get("csrf_token");
}
setRefreshToken(token: string) {
- Cookies.set("refreshToken", token);
+ Cookies.set("refresh_token", token, { expires: 30 });
}
getRefreshToken() {
- return Cookies.get("refreshToken");
+ return Cookies.get("refresh_token");
}
purgeRefreshToken() {
- Cookies.remove("refreshToken", { path: "/" });
+ Cookies.remove("refresh_token", { path: "/" });
}
setAccessToken(token: string) {
- Cookies.set("accessToken", token);
+ Cookies.set("access_token", token, { expires: 30 });
}
getAccessToken() {
- return Cookies.get("accessToken");
+ return Cookies.get("access_token");
}
purgeAccessToken() {
- Cookies.remove("accessToken", { path: "/" });
+ Cookies.remove("access_token", { path: "/" });
}
getHeaders() {
@@ -47,6 +53,7 @@ abstract class APIService {
url: this.baseURL + url,
headers: this.getAccessToken() ? this.getHeaders() : {},
...config,
+ withCredentials: true,
});
}
@@ -57,6 +64,7 @@ abstract class APIService {
data,
headers: this.getAccessToken() ? this.getHeaders() : {},
...config,
+ withCredentials: true,
});
}
@@ -67,6 +75,7 @@ abstract class APIService {
data,
headers: this.getAccessToken() ? this.getHeaders() : {},
...config,
+ withCredentials: true,
});
}
@@ -77,6 +86,7 @@ abstract class APIService {
data,
headers: this.getAccessToken() ? this.getHeaders() : {},
...config,
+ withCredentials: true,
});
}
@@ -87,16 +97,7 @@ abstract class APIService {
data: data,
headers: this.getAccessToken() ? this.getHeaders() : {},
...config,
- });
- }
-
- mediaUpload(url: string, data = {}, config = {}): Promise
{
- return axios({
- method: "post",
- url: this.baseURL + url,
- data,
- headers: this.getAccessToken() ? { ...this.getHeaders(), "Content-Type": "multipart/form-data" } : {},
- ...config,
+ withCredentials: true,
});
}
diff --git a/space/services/app-config.service.ts b/space/services/app-config.service.ts
deleted file mode 100644
index a6a1a9cf6..000000000
--- a/space/services/app-config.service.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-// services
-import APIService from "@/services/api.service";
-// helper
-import { API_BASE_URL } from "@/helpers/common.helper";
-// types
-import { IAppConfig } from "types/app";
-
-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/authentication.service.ts b/space/services/authentication.service.ts
index 0fbf0c71b..a602e6c43 100644
--- a/space/services/authentication.service.ts
+++ b/space/services/authentication.service.ts
@@ -1,137 +1,41 @@
+// types
+import { ICsrfTokenData, IEmailCheckData, IEmailCheckResponse } from "@plane/types";
+// helpers
+import { API_BASE_URL } from "@/helpers/common.helper";
// services
import APIService from "@/services/api.service";
-import { API_BASE_URL } from "@/helpers/common.helper";
-import { IEmailCheckData, IEmailCheckResponse, ILoginTokenResponse, IPasswordSignInData } from "types/auth";
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 emailCheck(data: IEmailCheckData): Promise {
- return this.post("/api/email-check/", data, { headers: {} })
+ return this.post("/auth/spaces/email-check/", data, { headers: {} })
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
- async emailLogin(data: any) {
- return this.post("/api/sign-in/", data, { headers: {} })
- .then((response) => {
- this.setAccessToken(response?.data?.access_token);
- this.setRefreshToken(response?.data?.refresh_token);
- return response?.data;
- })
- .catch((error) => {
- throw error?.response?.data;
- });
- }
-
- async emailSignUp(data: { email: string; password: string }) {
- return this.post("/api/sign-up/", data, { headers: {} })
- .then((response) => {
- this.setAccessToken(response?.data?.access_token);
- this.setRefreshToken(response?.data?.refresh_token);
- return response?.data;
- })
- .catch((error) => {
- throw error?.response?.data;
- });
- }
-
- async socialAuth(data: any): Promise<{
- access_token: string;
- refresh_toke: string;
- user: any;
- }> {
- return this.post("/api/social-auth/", data, { headers: {} })
- .then((response) => {
- this.setAccessToken(response?.data?.access_token);
- this.setRefreshToken(response?.data?.refresh_token);
- return response?.data;
- })
- .catch((error) => {
- throw error?.response?.data;
- });
- }
-
- async passwordSignIn(data: IPasswordSignInData): Promise {
- return this.post("/api/sign-in/", data, { headers: {} })
- .then((response) => {
- this.setAccessToken(response?.data?.access_token);
- this.setRefreshToken(response?.data?.refresh_token);
- return response?.data;
- })
- .catch((error) => {
- throw error?.response?.data;
- });
- }
-
async sendResetPasswordLink(data: { email: string }): Promise {
- return this.post(`/api/forgot-password/`, data)
+ return this.post(`/auth/forgot-password/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
- async emailCode(data: any) {
- return this.post("/api/magic-generate/", data, { headers: {} })
- .then((response) => response?.data)
- .catch((error) => {
- throw error?.response?.data;
- });
- }
-
- async forgotPassword(data: { email: string }): Promise {
- return this.post(`/api/forgot-password/`, data)
- .then((response) => response?.data)
- .catch((error) => {
- throw error?.response;
- });
- }
-
- async magicSignIn(data: any) {
- const response = await this.post("/api/magic-sign-in/", data, { headers: {} });
- if (response?.status === 200) {
- this.setAccessToken(response?.data?.access_token);
- this.setRefreshToken(response?.data?.refresh_token);
- return response?.data;
- }
- throw response.response.data;
- }
-
async generateUniqueCode(data: { email: string }): Promise {
- return this.post("/api/magic-generate/", data, { headers: {} })
- .then((response) => response?.data)
- .catch((error) => {
- throw error?.response?.data;
- });
- }
-
- async resetPassword(
- uidb64: string,
- token: string,
- data: {
- new_password: string;
- }
- ): Promise {
- return this.post(`/api/reset-password/${uidb64}/${token}/`, data, { headers: {} })
- .then((response) => {
- if (response?.status === 200) {
- this.setAccessToken(response?.data?.access_token);
- this.setRefreshToken(response?.data?.refresh_token);
- return response?.data;
- }
- })
- .catch((error) => {
- throw error?.response?.data;
- });
- }
-
- async setPassword(data: { password: string }): Promise {
- return this.post(`/api/users/me/set-password/`, data)
+ return this.post("/auth/spaces/magic-generate/", data, { headers: {} })
.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 52793ec75..33b528c23 100644
--- a/space/services/file.service.ts
+++ b/space/services/file.service.ts
@@ -1,8 +1,8 @@
-// services
-import APIService from "@/services/api.service";
+import axios from "axios";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
-import axios from "axios";
+// services
+import APIService from "@/services/api.service";
interface UnSplashImage {
id: string;
@@ -136,8 +136,14 @@ class FileService extends APIService {
throw error?.response?.data;
});
}
+
async uploadUserFile(file: FormData): Promise {
- return this.mediaUpload(`/api/users/file-assets/`, file)
+ return this.post(`/api/users/file-assets/`, file, {
+ headers: {
+ ...this.getHeaders(),
+ "Content-Type": "multipart/form-data",
+ },
+ })
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
diff --git a/space/services/instance.service.ts b/space/services/instance.service.ts
new file mode 100644
index 000000000..ad8dd5026
--- /dev/null
+++ b/space/services/instance.service.ts
@@ -0,0 +1,48 @@
+// types
+import type { IInstance } from "@plane/types";
+// helpers
+import { API_BASE_URL } from "@/helpers/common.helper";
+// services
+import APIService from "@/services/api.service";
+
+export class InstanceService extends APIService {
+ constructor() {
+ super(API_BASE_URL);
+ }
+
+ async requestCSRFToken(): Promise<{ csrf_token: string }> {
+ return this.get("/auth/get-csrf-token/")
+ .then((response) => {
+ this.setCSRFToken(response.data.csrf_token);
+ return response.data;
+ })
+ .catch((error) => {
+ throw error;
+ });
+ }
+
+ async getInstanceInfo(): Promise {
+ return this.get("/api/instances/")
+ .then((response) => response.data)
+ .catch((error) => {
+ throw error;
+ });
+ }
+
+ async createInstanceAdmin(data: FormData): Promise {
+ return this.post("/api/instances/admins/sign-in/", {
+ headers: {
+ "Content-Type": "multipart/form-data",
+ "X-CSRFToken": this.getCSRFToken(),
+ },
+ data,
+ })
+ .then((response) => {
+ console.log("response.data", response.data);
+ response.data;
+ })
+ .catch((error) => {
+ throw error;
+ });
+ }
+}
diff --git a/space/services/user.service.ts b/space/services/user.service.ts
index e49378d93..a08ccf837 100644
--- a/space/services/user.service.ts
+++ b/space/services/user.service.ts
@@ -1,9 +1,9 @@
-// services
-import APIService from "@/services/api.service";
+// types
+import { IUser, TUserProfile } from "@plane/types";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
-// types
-import { IUser } from "types/user";
+// services
+import APIService from "@/services/api.service";
export class UserService extends APIService {
constructor() {
@@ -18,11 +18,26 @@ export class UserService extends APIService {
});
}
- async updateMe(data: any): Promise {
+ async updateUser(data: Partial): Promise {
return this.patch("/api/users/me/", data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
+
+ async getCurrentUserProfile(): Promise {
+ return this.get("/api/users/me/profile/")
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response;
+ });
+ }
+ async updateCurrentUserProfile(data: any): Promise {
+ return this.patch("/api/users/me/profile/", data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response;
+ });
+ }
}
diff --git a/space/store/instance.store.ts b/space/store/instance.store.ts
new file mode 100644
index 000000000..e40e00bf3
--- /dev/null
+++ b/space/store/instance.store.ts
@@ -0,0 +1,91 @@
+import { observable, action, makeObservable, runInAction } from "mobx";
+// types
+import { IInstance } from "@plane/types";
+// services
+import { InstanceService } from "@/services/instance.service";
+import { RootStore } from "./root";
+
+type TError = {
+ status: string;
+ message: string;
+ data?: {
+ is_activated: boolean;
+ is_setup_done: boolean;
+ };
+};
+
+export interface IInstanceStore {
+ // issues
+ isLoading: boolean;
+ instance: IInstance | undefined;
+ error: TError | undefined;
+ // action
+ fetchInstanceInfo: () => Promise;
+}
+
+export class InstanceStore implements IInstanceStore {
+ isLoading: boolean = true;
+ instance: IInstance | undefined = undefined;
+ error: TError | undefined = undefined;
+ // root store
+ rootStore: RootStore;
+ // services
+ instanceService;
+
+ constructor(_rootStore: any) {
+ makeObservable(this, {
+ // observable
+ isLoading: observable.ref,
+ instance: observable,
+ error: observable,
+ // actions
+ fetchInstanceInfo: action,
+ });
+ this.rootStore = _rootStore;
+ // services
+ this.instanceService = new InstanceService();
+ }
+
+ /**
+ * @description fetching instance information
+ */
+ fetchInstanceInfo = async () => {
+ try {
+ runInAction(() => {
+ this.isLoading = true;
+ this.error = undefined;
+ });
+
+ const instance = await this.instanceService.getInstanceInfo();
+
+ const isInstanceNotSetup = (instance: IInstance) => "is_activated" in instance && "is_setup_done" in instance;
+
+ if (isInstanceNotSetup(instance)) {
+ runInAction(() => {
+ this.isLoading = false;
+ this.error = {
+ status: "success",
+ message: "Instance is not created in the backend",
+ data: {
+ is_activated: instance?.instance?.is_activated,
+ is_setup_done: instance?.instance?.is_setup_done,
+ },
+ };
+ });
+ } else {
+ runInAction(() => {
+ this.isLoading = false;
+ this.instance = instance;
+ });
+ }
+ } catch (error) {
+ runInAction(() => {
+ this.isLoading = false;
+ this.error = {
+ status: "error",
+ message: "Failed to fetch instance info",
+ };
+ });
+ }
+ };
+}
diff --git a/space/store/profile.ts b/space/store/profile.ts
new file mode 100644
index 000000000..b6512eda1
--- /dev/null
+++ b/space/store/profile.ts
@@ -0,0 +1,127 @@
+import { action, makeObservable, observable, runInAction } from "mobx";
+// services
+import { TUserProfile } from "@plane/types";
+import { UserService } from "services/user.service";
+// types
+import { RootStore } from "./root";
+
+type TError = {
+ status: string;
+ message: string;
+};
+
+export interface IProfileStore {
+ // observables
+ isLoading: boolean;
+ currentUserProfile: TUserProfile;
+ error: TError | undefined;
+ // actions
+ fetchUserProfile: () => Promise;
+ updateUserProfile: (currentUserProfile: Partial) => Promise;
+}
+
+class ProfileStore implements IProfileStore {
+ isLoading: boolean = false;
+ currentUserProfile: TUserProfile = {
+ id: undefined,
+ user: undefined,
+ role: undefined,
+ last_workspace_id: undefined,
+ theme: {
+ theme: undefined,
+ text: undefined,
+ palette: undefined,
+ primary: undefined,
+ background: undefined,
+ darkPalette: undefined,
+ sidebarText: undefined,
+ sidebarBackground: undefined,
+ },
+ onboarding_step: {
+ workspace_join: false,
+ profile_complete: false,
+ workspace_create: false,
+ workspace_invite: false,
+ },
+ is_onboarded: false,
+ is_tour_completed: false,
+ use_case: undefined,
+ billing_address_country: undefined,
+ billing_address: undefined,
+ has_billing_address: false,
+ created_at: "",
+ updated_at: "",
+ };
+ error: TError | undefined = undefined;
+ // root store
+ rootStore;
+ // services
+ userService: UserService;
+
+ constructor(_rootStore: RootStore) {
+ makeObservable(this, {
+ // observables
+ isLoading: observable.ref,
+ currentUserProfile: observable,
+ error: observable,
+ // actions
+ fetchUserProfile: action,
+ updateUserProfile: action,
+ });
+ this.rootStore = _rootStore;
+ // services
+ this.userService = new UserService();
+ }
+
+ // actions
+ fetchUserProfile = async () => {
+ try {
+ runInAction(() => {
+ this.isLoading = true;
+ this.error = undefined;
+ });
+ const userProfile = await this.userService.getCurrentUserProfile();
+ runInAction(() => {
+ this.isLoading = false;
+ this.currentUserProfile = userProfile;
+ });
+
+ return userProfile;
+ } catch (error) {
+ console.log("Failed to fetch profile details");
+ runInAction(() => {
+ this.isLoading = true;
+ this.error = {
+ status: "error",
+ message: "Failed to fetch instance info",
+ };
+ });
+ throw error;
+ }
+ };
+
+ updateUserProfile = async (currentUserProfile: Partial) => {
+ try {
+ runInAction(() => {
+ this.isLoading = true;
+ this.error = undefined;
+ });
+ const userProfile = await this.userService.updateCurrentUserProfile(currentUserProfile);
+ runInAction(() => {
+ this.isLoading = false;
+ this.currentUserProfile = userProfile;
+ });
+ } catch (error) {
+ console.log("Failed to fetch profile details");
+ runInAction(() => {
+ this.isLoading = true;
+ this.error = {
+ status: "error",
+ message: "Failed to fetch instance info",
+ };
+ });
+ }
+ };
+}
+
+export default ProfileStore;
diff --git a/space/store/root.ts b/space/store/root.ts
index 5a9e0bca1..f3278570e 100644
--- a/space/store/root.ts
+++ b/space/store/root.ts
@@ -1,17 +1,21 @@
// mobx lite
import { enableStaticRendering } from "mobx-react-lite";
// store imports
-import UserStore from "./user";
+import { InstanceStore } from "./instance.store";
import IssueStore, { IIssueStore } from "./issue";
-import ProjectStore, { IProjectStore } from "./project";
import IssueDetailStore, { IIssueDetailStore } from "./issue_details";
-import { IMentionsStore, MentionsStore } from "./mentions.store";
import { IIssuesFilterStore, IssuesFilterStore } from "./issues/issue-filters.store";
+import { IMentionsStore, MentionsStore } from "./mentions.store";
+import ProfileStore from "./profile";
+import ProjectStore, { IProjectStore } from "./project";
+import UserStore from "./user";
enableStaticRendering(typeof window === "undefined");
export class RootStore {
+ instanceStore: InstanceStore;
user: UserStore;
+ profile: ProfileStore;
issue: IIssueStore;
issueDetails: IIssueDetailStore;
project: IProjectStore;
@@ -19,7 +23,9 @@ export class RootStore {
issuesFilter: IIssuesFilterStore;
constructor() {
+ this.instanceStore = new InstanceStore(this);
this.user = new UserStore(this);
+ this.profile = new ProfileStore(this);
this.issue = new IssueStore(this);
this.project = new ProjectStore(this);
this.issueDetails = new IssueDetailStore(this);
diff --git a/space/store/user.ts b/space/store/user.ts
index 0e9b90106..93ca60f3a 100644
--- a/space/store/user.ts
+++ b/space/store/user.ts
@@ -1,15 +1,16 @@
// mobx
import { observable, action, computed, makeObservable, runInAction } from "mobx";
+// types
+import { IUser } from "@plane/types";
// service
import { UserService } from "@/services/user.service";
-// types
-import { IUser } from "types/user";
export interface IUserStore {
loader: boolean;
error: any | null;
currentUser: any | null;
fetchCurrentUser: () => Promise;
+ updateCurrentUser: (data: Partial) => Promise;
currentActor: () => any;
}
@@ -96,6 +97,23 @@ class UserStore implements IUserStore {
this.error = error;
}
};
+
+ /**
+ * Updates the current user
+ * @param data
+ * @returns Promise
+ */
+ updateCurrentUser = async (data: Partial) => {
+ try {
+ const user = await this.userService.updateUser(data);
+ runInAction(() => {
+ this.currentUser = user;
+ });
+ return user;
+ } catch (error) {
+ throw error;
+ }
+ };
}
export default UserStore;
diff --git a/turbo.json b/turbo.json
index 4e8c4ee81..893a4f39b 100644
--- a/turbo.json
+++ b/turbo.json
@@ -16,6 +16,7 @@
"NEXT_PUBLIC_DEPLOY_WITH_NGINX",
"NEXT_PUBLIC_POSTHOG_KEY",
"NEXT_PUBLIC_POSTHOG_HOST",
+ "NEXT_PUBLIC_GOD_MODE",
"NEXT_PUBLIC_POSTHOG_DEBUG",
"SENTRY_AUTH_TOKEN"
],
diff --git a/web/Dockerfile.web b/web/Dockerfile.web
index 5490fb0da..bed1c09ce 100644
--- a/web/Dockerfile.web
+++ b/web/Dockerfile.web
@@ -64,5 +64,6 @@ COPY start.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/start.sh
ENV NEXT_TELEMETRY_DISABLED 1
+ENV TURBO_TELEMETRY_DISABLED 1
EXPOSE 3000
diff --git a/web/components/account/auth-forms/email.tsx b/web/components/account/auth-forms/email.tsx
new file mode 100644
index 000000000..ed6465458
--- /dev/null
+++ b/web/components/account/auth-forms/email.tsx
@@ -0,0 +1,93 @@
+import React from "react";
+import { observer } from "mobx-react-lite";
+import { Controller, useForm } from "react-hook-form";
+// icons
+import { CircleAlert, XCircle } from "lucide-react";
+// types
+import { IEmailCheckData } from "@plane/types";
+// ui
+import { Button, Input } from "@plane/ui";
+// helpers
+import { checkEmailValidity } from "@/helpers/string.helper";
+
+type Props = {
+ onSubmit: (data: IEmailCheckData) => Promise;
+ defaultEmail: string;
+};
+
+type TEmailFormValues = {
+ email: string;
+};
+
+export const AuthEmailForm: React.FC = observer((props) => {
+ const { onSubmit, defaultEmail } = props;
+ // hooks
+ const {
+ control,
+ formState: { errors, isSubmitting, isValid },
+ handleSubmit,
+ } = useForm({
+ defaultValues: {
+ email: defaultEmail,
+ },
+ mode: "onChange",
+ reValidateMode: "onChange",
+ });
+
+ const handleFormSubmit = async (data: TEmailFormValues) => {
+ const payload: IEmailCheckData = {
+ email: data.email,
+ };
+ onSubmit(payload);
+ };
+
+ return (
+
+ );
+});
diff --git a/web/components/account/auth-forms/forgot-password-popover.tsx b/web/components/account/auth-forms/forgot-password-popover.tsx
new file mode 100644
index 000000000..31bafce26
--- /dev/null
+++ b/web/components/account/auth-forms/forgot-password-popover.tsx
@@ -0,0 +1,54 @@
+import { Fragment, useState } from "react";
+import { usePopper } from "react-popper";
+import { X } from "lucide-react";
+import { Popover } from "@headlessui/react";
+
+export const ForgotPasswordPopover = () => {
+ // popper-js refs
+ const [referenceElement, setReferenceElement] = useState(null);
+ const [popperElement, setPopperElement] = useState(null);
+ // popper-js init
+ const { styles, attributes } = usePopper(referenceElement, popperElement, {
+ placement: "right-start",
+ modifiers: [
+ {
+ name: "preventOverflow",
+ options: {
+ padding: 12,
+ },
+ },
+ ],
+ });
+
+ return (
+
+
+
+
+
+ {({ close }) => (
+
+
🤥
+
+ We see that your god hasn{"'"}t enabled SMTP, we will not be able to send a password reset link
+
+
+
+ )}
+
+
+ );
+};
diff --git a/web/components/account/sign-up-forms/index.ts b/web/components/account/auth-forms/index.ts
similarity index 71%
rename from web/components/account/sign-up-forms/index.ts
rename to web/components/account/auth-forms/index.ts
index f84d41abc..c607000c7 100644
--- a/web/components/account/sign-up-forms/index.ts
+++ b/web/components/account/auth-forms/index.ts
@@ -1,5 +1,5 @@
export * from "./email";
-export * from "./optional-set-password";
+export * from "./forgot-password-popover";
export * from "./password";
export * from "./root";
export * from "./unique-code";
diff --git a/web/components/account/auth-forms/password.tsx b/web/components/account/auth-forms/password.tsx
new file mode 100644
index 000000000..bffdf5a99
--- /dev/null
+++ b/web/components/account/auth-forms/password.tsx
@@ -0,0 +1,215 @@
+import React, { useEffect, useMemo, useState } from "react";
+import { observer } from "mobx-react";
+import Link from "next/link";
+// icons
+import { Eye, EyeOff, XCircle } from "lucide-react";
+// ui
+import { Button, Input } from "@plane/ui";
+// components
+import { EAuthModes, EAuthSteps, ForgotPasswordPopover, PasswordStrengthMeter } from "@/components/account";
+// constants
+import { FORGOT_PASSWORD } from "@/constants/event-tracker";
+// helpers
+import { API_BASE_URL } from "@/helpers/common.helper";
+import { getPasswordStrength } from "@/helpers/password.helper";
+// hooks
+import { useEventTracker, useInstance } from "@/hooks/store";
+// services
+import { AuthService } from "@/services/auth.service";
+
+type Props = {
+ email: string;
+ mode: EAuthModes;
+ handleStepChange: (step: EAuthSteps) => void;
+ handleEmailClear: () => void;
+};
+
+type TPasswordFormValues = {
+ email: string;
+ password: string;
+ confirm_password?: string;
+};
+
+const defaultValues: TPasswordFormValues = {
+ email: "",
+ password: "",
+};
+
+const authService = new AuthService();
+
+export const AuthPasswordForm: React.FC = observer((props: Props) => {
+ const { email, handleStepChange, handleEmailClear, mode } = 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 { instance } = useInstance();
+ const { captureEvent } = useEventTracker();
+ // 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 = async () => {
+ handleStepChange(EAuthSteps.UNIQUE_CODE);
+ };
+
+ const passwordSupport =
+ mode === EAuthModes.SIGN_IN ? (
+
+ {isSmtpConfigured ? (
+ captureEvent(FORGOT_PASSWORD)}
+ href={`/accounts/forgot-password?email=${email}`}
+ className="text-xs font-medium text-custom-primary-100"
+ >
+ 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 (
+ <>
+
+ >
+ );
+});
diff --git a/web/components/account/auth-forms/root.tsx b/web/components/account/auth-forms/root.tsx
new file mode 100644
index 000000000..6ed70422e
--- /dev/null
+++ b/web/components/account/auth-forms/root.tsx
@@ -0,0 +1,236 @@
+import React, { useEffect, useState } from "react";
+import isEmpty from "lodash/isEmpty";
+import { observer } from "mobx-react";
+import { useRouter } from "next/router";
+// types
+import { IEmailCheckData, IWorkspaceMemberInvitation } from "@plane/types";
+// ui
+import { Spinner, TOAST_TYPE, setToast } from "@plane/ui";
+// components
+import {
+ AuthEmailForm,
+ AuthPasswordForm,
+ OAuthOptions,
+ TermsAndConditions,
+ UniqueCodeForm,
+} from "@/components/account";
+import { WorkspaceLogo } from "@/components/workspace/logo";
+import { useInstance } from "@/hooks/store";
+// services
+import { AuthService } from "@/services/auth.service";
+import { WorkspaceService } from "@/services/workspace.service";
+
+const authService = new AuthService();
+const workSpaceService = new WorkspaceService();
+
+export enum EAuthSteps {
+ EMAIL = "EMAIL",
+ PASSWORD = "PASSWORD",
+ UNIQUE_CODE = "UNIQUE_CODE",
+ OPTIONAL_SET_PASSWORD = "OPTIONAL_SET_PASSWORD",
+}
+
+export enum EAuthModes {
+ SIGN_IN = "SIGN_IN",
+ SIGN_UP = "SIGN_UP",
+}
+
+type Props = {
+ mode: EAuthModes;
+};
+
+const Titles = {
+ [EAuthModes.SIGN_IN]: {
+ [EAuthSteps.EMAIL]: {
+ header: "Sign in to Plane",
+ subHeader: "Get back to your projects and make progress",
+ },
+ [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",
+ },
+ [EAuthSteps.OPTIONAL_SET_PASSWORD]: {
+ header: "",
+ subHeader: "",
+ },
+ },
+ [EAuthModes.SIGN_UP]: {
+ [EAuthSteps.EMAIL]: {
+ header: "Create your account",
+ subHeader: "Start tracking your projects with Plane",
+ },
+ [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.",
+ },
+ [EAuthSteps.OPTIONAL_SET_PASSWORD]: {
+ header: "",
+ subHeader: "",
+ },
+ },
+};
+
+const getHeaderSubHeader = (
+ step: EAuthSteps,
+ mode: EAuthModes,
+ invitation?: IWorkspaceMemberInvitation | undefined,
+ email?: string
+) => {
+ if (invitation && email && invitation.email === email && invitation.workspace) {
+ const workspace = invitation.workspace;
+ return {
+ header: (
+ <>
+ Join {workspace.name}
+ >
+ ),
+ subHeader: `${
+ mode == EAuthModes.SIGN_UP ? "Create an account" : "Sign in"
+ } to start managing work with your team.`,
+ };
+ }
+
+ return Titles[mode][step];
+};
+
+export const AuthRoot = observer((props: Props) => {
+ const { mode } = props;
+ //router
+ const router = useRouter();
+ const { email: emailParam, invitation_id, slug } = router.query;
+ // states
+ const [authStep, setAuthStep] = useState(EAuthSteps.EMAIL);
+ const [email, setEmail] = useState(emailParam ? emailParam.toString() : "");
+ const [invitation, setInvitation] = useState(undefined);
+ const [isLoading, setIsLoading] = useState(false);
+ // hooks
+ const { instance } = useInstance();
+ // derived values
+ const isSmtpConfigured = instance?.config?.is_smtp_configured;
+
+ const redirectToSignUp = (email: string) => {
+ if (isEmpty(email)) router.push({ pathname: "/", query: router.query });
+ else router.push({ pathname: "/", query: { ...router.query, email: email } });
+ };
+
+ const redirectToSignIn = (email: string) => {
+ if (isEmpty(email)) router.push({ pathname: "/accounts/sign-in", query: router.query });
+ else router.push({ pathname: "/accounts/sign-in", query: { ...router.query, email: email } });
+ };
+
+ useEffect(() => {
+ if (invitation_id && slug) {
+ setIsLoading(true);
+ workSpaceService
+ .getWorkspaceInvitation(slug.toString(), invitation_id.toString())
+ .then((res) => {
+ setInvitation(res);
+ })
+ .catch(() => {
+ setInvitation(undefined);
+ })
+ .finally(() => setIsLoading(false));
+ } else {
+ setInvitation(undefined);
+ }
+ }, [invitation_id, slug]);
+
+ const { header, subHeader } = getHeaderSubHeader(authStep, mode, invitation, email);
+
+ // step 1 submit handler- email verification
+ const handleEmailVerification = async (data: IEmailCheckData) => {
+ setEmail(data.email);
+
+ const emailCheck = mode === EAuthModes.SIGN_UP ? authService.signUpEmailCheck : authService.signInEmailCheck;
+
+ await emailCheck(data)
+ .then((res) => {
+ if (mode === EAuthModes.SIGN_IN && !res.is_password_autoset) {
+ setAuthStep(EAuthSteps.PASSWORD);
+ } else {
+ if (isSmtpConfigured) {
+ setAuthStep(EAuthSteps.UNIQUE_CODE);
+ } else {
+ if (mode === EAuthModes.SIGN_IN) {
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: "Error!",
+ message: "Unable to process request please contact Administrator to reset password",
+ });
+ } else {
+ setAuthStep(EAuthSteps.PASSWORD);
+ }
+ }
+ }
+ })
+ .catch((err) => {
+ if (err?.error_code === "USER_DOES_NOT_EXIST") {
+ redirectToSignUp(data.email);
+ return;
+ } else if (err?.error_code === "USER_ALREADY_EXIST") {
+ redirectToSignIn(data.email);
+ return;
+ }
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: "Error!",
+ message: err?.error_message ?? "Something went wrong. Please try again.",
+ });
+ });
+ };
+
+ const isOAuthEnabled =
+ instance?.config && (instance?.config?.is_google_enabled || instance?.config?.is_github_enabled);
+
+ if (isLoading)
+ return (
+
+
+
+ );
+
+ return (
+ <>
+
+
+
{header}
+
{subHeader}
+
+ {authStep === EAuthSteps.EMAIL &&
}
+ {authStep === EAuthSteps.UNIQUE_CODE && (
+
{
+ setEmail("");
+ setAuthStep(EAuthSteps.EMAIL);
+ }}
+ submitButtonText="Continue"
+ mode={mode}
+ />
+ )}
+ {authStep === EAuthSteps.PASSWORD && (
+ {
+ setEmail("");
+ setAuthStep(EAuthSteps.EMAIL);
+ }}
+ handleStepChange={(step) => setAuthStep(step)}
+ mode={mode}
+ />
+ )}
+
+ {isOAuthEnabled && authStep !== EAuthSteps.OPTIONAL_SET_PASSWORD && }
+
+
+ >
+ );
+});
diff --git a/web/components/account/auth-forms/unique-code.tsx b/web/components/account/auth-forms/unique-code.tsx
new file mode 100644
index 000000000..7aa51d45b
--- /dev/null
+++ b/web/components/account/auth-forms/unique-code.tsx
@@ -0,0 +1,173 @@
+import React, { useEffect, useState } from "react";
+import { CircleCheck, XCircle } from "lucide-react";
+// types
+import { IEmailCheckData } from "@plane/types";
+// ui
+import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui";
+// constants
+// helpers
+import { API_BASE_URL } from "@/helpers/common.helper";
+// hooks
+import useTimer from "@/hooks/use-timer";
+// services
+import { AuthService } from "@/services/auth.service";
+import { EAuthModes } from "./root";
+
+type Props = {
+ email: string;
+ handleEmailClear: () => void;
+ submitButtonText: string;
+ mode: EAuthModes;
+};
+
+type TUniqueCodeFormValues = {
+ email: string;
+ code: string;
+};
+
+const defaultValues: TUniqueCodeFormValues = {
+ email: "",
+ code: "",
+};
+
+// services
+const authService = new AuthService();
+
+export const UniqueCodeForm: React.FC = (props) => {
+ const { email, handleEmailClear, submitButtonText, mode } = props;
+ // states
+ const [uniqueCodeFormData, setUniqueCodeFormData] = useState({ ...defaultValues, email });
+ const [isRequestingNewCode, setIsRequestingNewCode] = useState(false);
+ const [csrfToken, setCsrfToken] = useState(undefined);
+ // store hooks
+ // const { captureEvent } = useEventTracker();
+ // 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);
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: "Success!",
+ message: "A new unique code has been sent to your email.",
+ });
+ handleFormChange("code", "");
+ })
+ .catch((err) =>
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: "Error!",
+ message: err?.error ?? "Something went wrong while generating unique code. 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 (
+
+ );
+};
diff --git a/web/components/account/deactivate-account-modal.tsx b/web/components/account/deactivate-account-modal.tsx
index 41508ad67..a020bd43b 100644
--- a/web/components/account/deactivate-account-modal.tsx
+++ b/web/components/account/deactivate-account-modal.tsx
@@ -46,7 +46,7 @@ export const DeactivateAccountModal: React.FC = (props) => {
router.push("/");
handleClose();
})
- .catch((err) =>
+ .catch((err: any) =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
diff --git a/web/components/account/index.ts b/web/components/account/index.ts
index 18b021cb9..42b3e5e38 100644
--- a/web/components/account/index.ts
+++ b/web/components/account/index.ts
@@ -1,5 +1,5 @@
-export * from "./o-auth";
-export * from "./sign-in-forms";
-export * from "./sign-up-forms";
+export * from "./oauth";
+export * from "./auth-forms";
export * from "./deactivate-account-modal";
export * from "./terms-and-conditions";
+export * from "./password-strength-meter";
diff --git a/web/components/account/o-auth/index.ts b/web/components/account/o-auth/index.ts
deleted file mode 100644
index 4cea6ce5b..000000000
--- a/web/components/account/o-auth/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export * from "./github-sign-in";
-export * from "./google-sign-in";
-export * from "./o-auth-options";
diff --git a/web/components/account/o-auth/o-auth-options.tsx b/web/components/account/o-auth/o-auth-options.tsx
deleted file mode 100644
index 4e2c5e288..000000000
--- a/web/components/account/o-auth/o-auth-options.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-import { observer } from "mobx-react-lite";
-// ui
-import { TOAST_TYPE, setToast } from "@plane/ui";
-// components
-import { GitHubSignInButton, GoogleSignInButton } from "@/components/account";
-// hooks
-import { useApplication } from "@/hooks/store";
-// services
-import { AuthService } from "@/services/auth.service";
-
-type Props = {
- handleSignInRedirection: () => Promise;
- type: "sign_in" | "sign_up";
-};
-
-// services
-const authService = new AuthService();
-
-export const OAuthOptions: React.FC = observer((props) => {
- const { handleSignInRedirection, type } = props;
- // mobx store
- const {
- config: { envConfig },
- } = useApplication();
- // derived values
- const areBothOAuthEnabled = envConfig?.google_client_id && envConfig?.github_client_id;
-
- const handleGoogleSignIn = async ({ clientId, credential }: any) => {
- try {
- if (clientId && credential) {
- const socialAuthPayload = {
- medium: "google",
- credential,
- clientId,
- };
- const response = await authService.socialAuth(socialAuthPayload);
-
- if (response) handleSignInRedirection();
- } else throw Error("Can't find credentials");
- } catch (err: any) {
- setToast({
- type: TOAST_TYPE.ERROR,
- title: "Error signing in!",
- message: err?.error || "Something went wrong. Please try again later or contact the support team.",
- });
- }
- };
-
- const handleGitHubSignIn = async (credential: string) => {
- try {
- if (envConfig && envConfig.github_client_id && credential) {
- const socialAuthPayload = {
- medium: "github",
- credential,
- clientId: envConfig.github_client_id,
- };
- const response = await authService.socialAuth(socialAuthPayload);
-
- if (response) handleSignInRedirection();
- } else throw Error("Can't find credentials");
- } catch (err: any) {
- setToast({
- type: TOAST_TYPE.ERROR,
- title: "Error signing in!",
- message: err?.error || "Something went wrong. Please try again later or contact the support team.",
- });
- }
- };
-
- return (
- <>
-
-
- {envConfig?.google_client_id && (
-
-
-
- )}
- {envConfig?.github_client_id && (
-
- )}
-
- >
- );
-});
diff --git a/web/components/account/oauth/github-button.tsx b/web/components/account/oauth/github-button.tsx
new file mode 100644
index 000000000..53fd6ed4f
--- /dev/null
+++ b/web/components/account/oauth/github-button.tsx
@@ -0,0 +1,39 @@
+import { FC } from "react";
+import Image from "next/image";
+import { useTheme } from "next-themes";
+// helpers
+import { API_BASE_URL } from "@/helpers/common.helper";
+// images
+import githubLightModeImage from "/public/logos/github-black.png";
+import githubDarkModeImage from "/public/logos/github-dark.svg";
+
+export type GithubOAuthButtonProps = {
+ text: string;
+};
+
+export const GithubOAuthButton: FC = (props) => {
+ const { text } = props;
+ // hooks
+ const { resolvedTheme } = useTheme();
+
+ const handleSignIn = () => {
+ window.location.assign(`${API_BASE_URL}/auth/github/`);
+ };
+
+ return (
+
+ );
+};
diff --git a/web/components/account/o-auth/github-sign-in.tsx b/web/components/account/oauth/github-sign-in.tsx
similarity index 97%
rename from web/components/account/o-auth/github-sign-in.tsx
rename to web/components/account/oauth/github-sign-in.tsx
index 74bfd6d94..1f0f56225 100644
--- a/web/components/account/o-auth/github-sign-in.tsx
+++ b/web/components/account/oauth/github-sign-in.tsx
@@ -50,8 +50,8 @@ export const GitHubSignInButton: FC = (props) => {
>
{type === "sign_in" ? "Sign-in" : "Sign-up"} with GitHub
diff --git a/web/components/account/oauth/google-button.tsx b/web/components/account/oauth/google-button.tsx
new file mode 100644
index 000000000..2b3df4931
--- /dev/null
+++ b/web/components/account/oauth/google-button.tsx
@@ -0,0 +1,33 @@
+import { FC } from "react";
+import Image from "next/image";
+import { useTheme } from "next-themes";
+// helpers
+import { API_BASE_URL } from "@/helpers/common.helper";
+// images
+import GoogleLogo from "/public/logos/google-logo.svg";
+
+export type GoogleOAuthButtonProps = {
+ text: string;
+};
+
+export const GoogleOAuthButton: FC = (props) => {
+ const { text } = props;
+ // hooks
+ const { resolvedTheme } = useTheme();
+
+ const handleSignIn = () => {
+ window.location.assign(`${API_BASE_URL}/auth/google/`);
+ };
+
+ return (
+
+ );
+};
diff --git a/web/components/account/o-auth/google-sign-in.tsx b/web/components/account/oauth/google-sign-in.tsx
similarity index 100%
rename from web/components/account/o-auth/google-sign-in.tsx
rename to web/components/account/oauth/google-sign-in.tsx
diff --git a/web/components/account/oauth/index.ts b/web/components/account/oauth/index.ts
new file mode 100644
index 000000000..b15990a90
--- /dev/null
+++ b/web/components/account/oauth/index.ts
@@ -0,0 +1,5 @@
+export * from "./github-sign-in";
+export * from "./google-sign-in";
+export * from "./oauth-options";
+export * from "./google-button";
+export * from "./github-button";
diff --git a/web/components/account/oauth/oauth-options.tsx b/web/components/account/oauth/oauth-options.tsx
new file mode 100644
index 000000000..5f2c528fd
--- /dev/null
+++ b/web/components/account/oauth/oauth-options.tsx
@@ -0,0 +1,28 @@
+import { observer } from "mobx-react";
+// components
+import { GithubOAuthButton, GoogleOAuthButton } from "@/components/account";
+// hooks
+import { useInstance } from "@/hooks/store";
+
+export const OAuthOptions: React.FC = observer(() => {
+ // hooks
+ const { instance } = useInstance();
+
+ return (
+ <>
+
+
+ {instance?.config?.is_google_enabled && (
+
+
+
+ )}
+ {instance?.config?.is_github_enabled &&
}
+
+ >
+ );
+});
diff --git a/web/components/account/password-strength-meter.tsx b/web/components/account/password-strength-meter.tsx
new file mode 100644
index 000000000..86ee814c8
--- /dev/null
+++ b/web/components/account/password-strength-meter.tsx
@@ -0,0 +1,67 @@
+// icons
+import { CircleCheck } from "lucide-react";
+// helpers
+import { cn } from "@/helpers/common.helper";
+import { getPasswordStrength } from "@/helpers/password.helper";
+
+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/web/components/account/sign-in-forms/email.tsx b/web/components/account/sign-in-forms/email.tsx
deleted file mode 100644
index 76051e94d..000000000
--- a/web/components/account/sign-in-forms/email.tsx
+++ /dev/null
@@ -1,107 +0,0 @@
-import React from "react";
-import { observer } from "mobx-react-lite";
-import { Controller, useForm } from "react-hook-form";
-import { XCircle } from "lucide-react";
-import { IEmailCheckData } from "@plane/types";
-// services
-import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui";
-import { checkEmailValidity } from "@/helpers/string.helper";
-import { AuthService } from "@/services/auth.service";
-// ui
-// helpers
-// types
-
-type Props = {
- onSubmit: (isPasswordAutoset: boolean) => void;
- updateEmail: (email: string) => void;
-};
-
-type TEmailFormValues = {
- email: string;
-};
-
-const authService = new AuthService();
-
-export const SignInEmailForm: React.FC = observer((props) => {
- const { onSubmit, updateEmail } = props;
- // hooks
- const {
- control,
- formState: { errors, isSubmitting, isValid },
- handleSubmit,
- } = useForm({
- defaultValues: {
- email: "",
- },
- mode: "onChange",
- reValidateMode: "onChange",
- });
-
- const handleFormSubmit = async (data: TEmailFormValues) => {
- const payload: IEmailCheckData = {
- email: data.email,
- };
-
- // update the global email state
- updateEmail(data.email);
-
- await authService
- .emailCheck(payload)
- .then((res) => onSubmit(res.is_password_autoset))
- .catch((err) =>
- setToast({
- type: TOAST_TYPE.ERROR,
- title: "Error!",
- message: err?.error ?? "Something went wrong. Please try again.",
- })
- );
- };
-
- return (
- <>
-
- Welcome back, let{"'"}s get you on board
-
-
- Get back to your issues, projects and workspaces.
-
-
-
- >
- );
-});
diff --git a/web/components/account/sign-in-forms/optional-set-password.tsx b/web/components/account/sign-in-forms/optional-set-password.tsx
deleted file mode 100644
index 26ec05aa5..000000000
--- a/web/components/account/sign-in-forms/optional-set-password.tsx
+++ /dev/null
@@ -1,189 +0,0 @@
-import React, { useState } from "react";
-import { Controller, useForm } from "react-hook-form";
-// services
-// hooks
-// ui
-import { Eye, EyeOff } from "lucide-react";
-import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui";
-// helpers
-import { PASSWORD_CREATE_SELECTED, PASSWORD_CREATE_SKIPPED } from "@/constants/event-tracker";
-import { checkEmailValidity } from "@/helpers/string.helper";
-// icons
-import { useEventTracker } from "@/hooks/store";
-import { AuthService } from "@/services/auth.service";
-
-type Props = {
- email: string;
- handleSignInRedirection: () => Promise;
-};
-
-type TCreatePasswordFormValues = {
- email: string;
- password: string;
-};
-
-const defaultValues: TCreatePasswordFormValues = {
- email: "",
- password: "",
-};
-
-// services
-const authService = new AuthService();
-
-export const SignInOptionalSetPasswordForm: React.FC = (props) => {
- const { email, handleSignInRedirection } = props;
- // states
- const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false);
- const [showPassword, setShowPassword] = useState(false);
- // store hooks
- const { captureEvent } = useEventTracker();
- // form info
- const {
- control,
- formState: { errors, isSubmitting, isValid },
- handleSubmit,
- } = useForm({
- defaultValues: {
- ...defaultValues,
- email,
- },
- mode: "onChange",
- reValidateMode: "onChange",
- });
-
- const handleCreatePassword = async (formData: TCreatePasswordFormValues) => {
- const payload = {
- password: formData.password,
- };
-
- await authService
- .setPassword(payload)
- .then(async () => {
- setToast({
- type: TOAST_TYPE.SUCCESS,
- title: "Success!",
- message: "Password created successfully.",
- });
- captureEvent(PASSWORD_CREATE_SELECTED, {
- state: "SUCCESS",
- first_time: false,
- });
- await handleSignInRedirection();
- })
- .catch((err) => {
- captureEvent(PASSWORD_CREATE_SELECTED, {
- state: "FAILED",
- first_time: false,
- });
- setToast({
- type: TOAST_TYPE.ERROR,
- title: "Error!",
- message: err?.error ?? "Something went wrong. Please try again.",
- });
- });
- };
-
- const handleGoToWorkspace = async () => {
- setIsGoingToWorkspace(true);
- await handleSignInRedirection().finally(() => {
- captureEvent(PASSWORD_CREATE_SKIPPED, {
- state: "SUCCESS",
- first_time: false,
- });
- setIsGoingToWorkspace(false);
- });
- };
-
- return (
- <>
- Set your password
-
- If you{"'"}d like to do away with codes, set a password here.
-
-
- >
- );
-};
diff --git a/web/components/account/sign-in-forms/password.tsx b/web/components/account/sign-in-forms/password.tsx
deleted file mode 100644
index 8d7c9f891..000000000
--- a/web/components/account/sign-in-forms/password.tsx
+++ /dev/null
@@ -1,232 +0,0 @@
-import React, { useState } from "react";
-import { observer } from "mobx-react-lite";
-import Link from "next/link";
-import { Controller, useForm } from "react-hook-form";
-import { Eye, EyeOff, XCircle } from "lucide-react";
-import { IPasswordSignInData } from "@plane/types";
-// services
-import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui";
-import { ESignInSteps, ForgotPasswordPopover } from "@/components/account";
-import { FORGOT_PASSWORD, SIGN_IN_WITH_PASSWORD } from "@/constants/event-tracker";
-import { checkEmailValidity } from "@/helpers/string.helper";
-import { useApplication, useEventTracker } from "@/hooks/store";
-import { AuthService } from "@/services/auth.service";
-// hooks
-// components
-// ui
-// helpers
-// types
-// constants
-
-type Props = {
- email: string;
- handleStepChange: (step: ESignInSteps) => void;
- handleEmailClear: () => void;
- onSubmit: () => Promise;
-};
-
-type TPasswordFormValues = {
- email: string;
- password: string;
-};
-
-const defaultValues: TPasswordFormValues = {
- email: "",
- password: "",
-};
-
-const authService = new AuthService();
-
-export const SignInPasswordForm: React.FC = observer((props) => {
- const { email, handleStepChange, handleEmailClear, onSubmit } = props;
- // states
- const [isSendingUniqueCode, setIsSendingUniqueCode] = useState(false);
- const [showPassword, setShowPassword] = useState(false);
- const {
- config: { envConfig },
- } = useApplication();
- const { captureEvent } = useEventTracker();
- // derived values
- const isSmtpConfigured = envConfig?.is_smtp_configured;
- // form info
- const {
- control,
- formState: { errors, isSubmitting, isValid },
- getValues,
- handleSubmit,
- setError,
- } = useForm({
- defaultValues: {
- ...defaultValues,
- email,
- },
- mode: "onChange",
- reValidateMode: "onChange",
- });
-
- const handleFormSubmit = async (formData: TPasswordFormValues) => {
- const payload: IPasswordSignInData = {
- email: formData.email,
- password: formData.password,
- };
-
- await authService
- .passwordSignIn(payload)
- .then(async () => {
- captureEvent(SIGN_IN_WITH_PASSWORD, {
- state: "SUCCESS",
- first_time: false,
- });
- await onSubmit();
- })
- .catch((err) =>
- setToast({
- type: TOAST_TYPE.ERROR,
- title: "Error!",
- message: err?.error ?? "Something went wrong. Please try again.",
- })
- );
- };
-
- const handleSendUniqueCode = async () => {
- const emailFormValue = getValues("email");
-
- const isEmailValid = checkEmailValidity(emailFormValue);
-
- if (!isEmailValid) {
- setError("email", { message: "Email is invalid" });
- return;
- }
-
- setIsSendingUniqueCode(true);
-
- await authService
- .generateUniqueCode({ email: emailFormValue })
- .then(() => handleStepChange(ESignInSteps.USE_UNIQUE_CODE_FROM_PASSWORD))
- .catch((err) =>
- setToast({
- type: TOAST_TYPE.ERROR,
- title: "Error!",
- message: err?.error ?? "Something went wrong. Please try again.",
- })
- )
- .finally(() => setIsSendingUniqueCode(false));
- };
-
- return (
- <>
-
- Welcome back, let{"'"}s get you on board
-
-
- Get back to your issues, projects and workspaces.
-
-
- >
- );
-});
diff --git a/web/components/account/sign-in-forms/root.tsx b/web/components/account/sign-in-forms/root.tsx
deleted file mode 100644
index a03bd379e..000000000
--- a/web/components/account/sign-in-forms/root.tsx
+++ /dev/null
@@ -1,131 +0,0 @@
-import React, { useEffect, useState } from "react";
-import { observer } from "mobx-react-lite";
-import Link from "next/link";
-// hooks
-import {
- SignInEmailForm,
- SignInUniqueCodeForm,
- SignInPasswordForm,
- OAuthOptions,
- SignInOptionalSetPasswordForm,
- TermsAndConditions,
-} from "@/components/account";
-import { LatestFeatureBlock } from "@/components/common";
-import { NAVIGATE_TO_SIGNUP } from "@/constants/event-tracker";
-import { useApplication, useEventTracker } from "@/hooks/store";
-import useSignInRedirection from "@/hooks/use-sign-in-redirection";
-// components
-// constants
-
-export enum ESignInSteps {
- EMAIL = "EMAIL",
- PASSWORD = "PASSWORD",
- UNIQUE_CODE = "UNIQUE_CODE",
- OPTIONAL_SET_PASSWORD = "OPTIONAL_SET_PASSWORD",
- USE_UNIQUE_CODE_FROM_PASSWORD = "USE_UNIQUE_CODE_FROM_PASSWORD",
-}
-
-export const SignInRoot = observer(() => {
- // states
- const [signInStep, setSignInStep] = useState(null);
- const [email, setEmail] = useState("");
- // sign in redirection hook
- const { handleRedirection } = useSignInRedirection();
- // mobx store
- const {
- config: { envConfig },
- } = useApplication();
- const { captureEvent } = useEventTracker();
- // derived values
- const isSmtpConfigured = envConfig?.is_smtp_configured;
-
- // step 1 submit handler- email verification
- const handleEmailVerification = (isPasswordAutoset: boolean) => {
- if (isSmtpConfigured && isPasswordAutoset) setSignInStep(ESignInSteps.UNIQUE_CODE);
- else setSignInStep(ESignInSteps.PASSWORD);
- };
-
- // step 2 submit handler- unique code sign in
- const handleUniqueCodeSignIn = async (isPasswordAutoset: boolean) => {
- if (isPasswordAutoset) setSignInStep(ESignInSteps.OPTIONAL_SET_PASSWORD);
- else await handleRedirection();
- };
-
- // step 3 submit handler- password sign in
- const handlePasswordSignIn = async () => {
- await handleRedirection();
- };
-
- const isOAuthEnabled = envConfig && (envConfig.google_client_id || envConfig.github_client_id);
-
- useEffect(() => {
- if (isSmtpConfigured) setSignInStep(ESignInSteps.EMAIL);
- else setSignInStep(ESignInSteps.PASSWORD);
- }, [isSmtpConfigured]);
-
- return (
- <>
-
- <>
- {signInStep === ESignInSteps.EMAIL && (
- setEmail(newEmail)} />
- )}
- {signInStep === ESignInSteps.UNIQUE_CODE && (
- {
- setEmail("");
- setSignInStep(ESignInSteps.EMAIL);
- }}
- onSubmit={handleUniqueCodeSignIn}
- submitButtonText="Continue"
- />
- )}
- {signInStep === ESignInSteps.PASSWORD && (
- {
- setEmail("");
- setSignInStep(ESignInSteps.EMAIL);
- }}
- onSubmit={handlePasswordSignIn}
- handleStepChange={(step) => setSignInStep(step)}
- />
- )}
- {signInStep === ESignInSteps.USE_UNIQUE_CODE_FROM_PASSWORD && (
- {
- setEmail("");
- setSignInStep(ESignInSteps.EMAIL);
- }}
- onSubmit={handleUniqueCodeSignIn}
- submitButtonText="Go to workspace"
- />
- )}
- {signInStep === ESignInSteps.OPTIONAL_SET_PASSWORD && (
-
- )}
- >
-
- {isOAuthEnabled &&
- (signInStep === ESignInSteps.EMAIL || (!isSmtpConfigured && signInStep === ESignInSteps.PASSWORD)) && (
- <>
-
-
- Don{"'"}t have an account?{" "}
- captureEvent(NAVIGATE_TO_SIGNUP, {})}
- className="text-custom-primary-100 font-medium underline"
- >
- Sign up
-
-
-
- >
- )}
-
- >
- );
-});
diff --git a/web/components/account/sign-in-forms/unique-code.tsx b/web/components/account/sign-in-forms/unique-code.tsx
deleted file mode 100644
index 2a9144469..000000000
--- a/web/components/account/sign-in-forms/unique-code.tsx
+++ /dev/null
@@ -1,217 +0,0 @@
-import React, { useState } from "react";
-import { Controller, useForm } from "react-hook-form";
-import { XCircle } from "lucide-react";
-import { IEmailCheckData, IMagicSignInData } from "@plane/types";
-// services
-import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui";
-
-import { CODE_VERIFIED } from "@/constants/event-tracker";
-import { checkEmailValidity } from "@/helpers/string.helper";
-import { useEventTracker } from "@/hooks/store";
-
-import useTimer from "@/hooks/use-timer";
-import { AuthService } from "@/services/auth.service";
-import { UserService } from "@/services/user.service";
-// hooks
-// ui
-// helpers
-// types
-// constants
-
-type Props = {
- email: string;
- onSubmit: (isPasswordAutoset: boolean) => Promise;
- handleEmailClear: () => void;
- submitButtonText: string;
-};
-
-type TUniqueCodeFormValues = {
- email: string;
- token: string;
-};
-
-const defaultValues: TUniqueCodeFormValues = {
- email: "",
- token: "",
-};
-
-// services
-const authService = new AuthService();
-const userService = new UserService();
-
-export const SignInUniqueCodeForm: React.FC = (props) => {
- const { email, onSubmit, handleEmailClear, submitButtonText } = props;
- // states
- const [isRequestingNewCode, setIsRequestingNewCode] = useState(false);
- // store hooks
- const { captureEvent } = useEventTracker();
- // timer
- const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(30);
- // form info
- const {
- control,
- formState: { errors, isSubmitting, isValid },
- getValues,
- handleSubmit,
- reset,
- } = useForm({
- defaultValues: {
- ...defaultValues,
- email,
- },
- mode: "onChange",
- reValidateMode: "onChange",
- });
-
- const handleUniqueCodeSignIn = async (formData: TUniqueCodeFormValues) => {
- const payload: IMagicSignInData = {
- email: formData.email,
- key: `magic_${formData.email}`,
- token: formData.token,
- };
-
- await authService
- .magicSignIn(payload)
- .then(async () => {
- captureEvent(CODE_VERIFIED, {
- state: "SUCCESS",
- });
- const currentUser = await userService.currentUser();
- await onSubmit(currentUser.is_password_autoset);
- })
- .catch((err) => {
- captureEvent(CODE_VERIFIED, {
- state: "FAILED",
- });
- setToast({
- type: TOAST_TYPE.ERROR,
- title: "Error!",
- message: err?.error ?? "Something went wrong. Please try again.",
- });
- });
- };
-
- const handleSendNewCode = async (formData: TUniqueCodeFormValues) => {
- const payload: IEmailCheckData = {
- email: formData.email,
- };
-
- await authService
- .generateUniqueCode(payload)
- .then(() => {
- setResendCodeTimer(30);
- setToast({
- type: TOAST_TYPE.SUCCESS,
- title: "Success!",
- message: "A new unique code has been sent to your email.",
- });
-
- reset({
- email: formData.email,
- token: "",
- });
- })
- .catch((err) =>
- setToast({
- type: TOAST_TYPE.ERROR,
- title: "Error!",
- message: err?.error ?? "Something went wrong. Please try again.",
- })
- );
- };
-
- const handleRequestNewCode = async () => {
- setIsRequestingNewCode(true);
-
- await handleSendNewCode(getValues())
- .then(() => setResendCodeTimer(30))
- .finally(() => setIsRequestingNewCode(false));
- };
-
- const isRequestNewCodeDisabled = isRequestingNewCode || resendTimerCode > 0;
-
- return (
- <>
- Moving to the runway
-
- Paste the code you got at
-
- {email} below.
-
-
- >
- );
-};
diff --git a/web/components/account/sign-up-forms/email.tsx b/web/components/account/sign-up-forms/email.tsx
deleted file mode 100644
index bc4fb1d86..000000000
--- a/web/components/account/sign-up-forms/email.tsx
+++ /dev/null
@@ -1,107 +0,0 @@
-import React from "react";
-import { observer } from "mobx-react-lite";
-import { Controller, useForm } from "react-hook-form";
-import { XCircle } from "lucide-react";
-import { IEmailCheckData } from "@plane/types";
-// services
-import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui";
-import { checkEmailValidity } from "@/helpers/string.helper";
-import { AuthService } from "@/services/auth.service";
-// ui
-// helpers
-// types
-
-type Props = {
- onSubmit: () => void;
- updateEmail: (email: string) => void;
-};
-
-type TEmailFormValues = {
- email: string;
-};
-
-const authService = new AuthService();
-
-export const SignUpEmailForm: React.FC = observer((props) => {
- const { onSubmit, updateEmail } = props;
- // hooks
- const {
- control,
- formState: { errors, isSubmitting, isValid },
- handleSubmit,
- } = useForm({
- defaultValues: {
- email: "",
- },
- mode: "onChange",
- reValidateMode: "onChange",
- });
-
- const handleFormSubmit = async (data: TEmailFormValues) => {
- const payload: IEmailCheckData = {
- email: data.email,
- };
-
- // update the global email state
- updateEmail(data.email);
-
- await authService
- .emailCheck(payload)
- .then(() => onSubmit())
- .catch((err) =>
- setToast({
- type: TOAST_TYPE.SUCCESS,
- title: "Error!",
- message: err?.error ?? "Something went wrong. Please try again.",
- })
- );
- };
-
- return (
- <>
-
- Get on your flight deck
-
-
- Create or join a workspace. Start with your e-mail.
-
-
-
- >
- );
-});
diff --git a/web/components/account/sign-up-forms/optional-set-password.tsx b/web/components/account/sign-up-forms/optional-set-password.tsx
deleted file mode 100644
index c269c389a..000000000
--- a/web/components/account/sign-up-forms/optional-set-password.tsx
+++ /dev/null
@@ -1,194 +0,0 @@
-import React, { useState } from "react";
-import { Controller, useForm } from "react-hook-form";
-// services
-import { Eye, EyeOff } from "lucide-react";
-import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui";
-import { ESignUpSteps } from "@/components/account";
-import { PASSWORD_CREATE_SKIPPED, SETUP_PASSWORD } from "@/constants/event-tracker";
-import { checkEmailValidity } from "@/helpers/string.helper";
-import { useEventTracker } from "@/hooks/store";
-import { AuthService } from "@/services/auth.service";
-// hooks
-// ui
-// helpers
-// components
-// constants
-// icons
-
-type Props = {
- email: string;
- handleStepChange: (step: ESignUpSteps) => void;
- handleSignInRedirection: () => Promise;
-};
-
-type TCreatePasswordFormValues = {
- email: string;
- password: string;
-};
-
-const defaultValues: TCreatePasswordFormValues = {
- email: "",
- password: "",
-};
-
-// services
-const authService = new AuthService();
-
-export const SignUpOptionalSetPasswordForm: React.FC = (props) => {
- const { email, handleSignInRedirection } = props;
- // states
- const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false);
- const [showPassword, setShowPassword] = useState(false);
- // store hooks
- const { captureEvent } = useEventTracker();
- // form info
- const {
- control,
- formState: { errors, isSubmitting, isValid },
- handleSubmit,
- } = useForm({
- defaultValues: {
- ...defaultValues,
- email,
- },
- mode: "onChange",
- reValidateMode: "onChange",
- });
-
- const handleCreatePassword = async (formData: TCreatePasswordFormValues) => {
- const payload = {
- password: formData.password,
- };
-
- await authService
- .setPassword(payload)
- .then(async () => {
- setToast({
- type: TOAST_TYPE.SUCCESS,
- title: "Success!",
- message: "Password created successfully.",
- });
- captureEvent(SETUP_PASSWORD, {
- state: "SUCCESS",
- first_time: true,
- });
- await handleSignInRedirection();
- })
- .catch((err) => {
- captureEvent(SETUP_PASSWORD, {
- state: "FAILED",
- first_time: true,
- });
- setToast({
- type: TOAST_TYPE.ERROR,
- title: "Error!",
- message: err?.error ?? "Something went wrong. Please try again.",
- });
- });
- };
-
- const handleGoToWorkspace = async () => {
- setIsGoingToWorkspace(true);
- await handleSignInRedirection().finally(() => {
- captureEvent(PASSWORD_CREATE_SKIPPED, {
- state: "SUCCESS",
- first_time: true,
- });
- setIsGoingToWorkspace(false);
- });
- };
-
- return (
- <>
- Moving to the runway
-
- Let{"'"}s set a password so
-
- you can do away with codes.
-
-