+
+ );
+ }
+
+ return <>{children}>;
+});
diff --git a/admin/lib/store-provider.tsx b/admin/lib/store-provider.tsx
new file mode 100644
index 000000000..842513860
--- /dev/null
+++ b/admin/lib/store-provider.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import { ReactNode, createContext } from "react";
+// store
+import { RootStore } from "@/store/root.store";
+
+let rootStore = new RootStore();
+
+export const StoreContext = createContext(rootStore);
+
+function initializeStore(initialData = {}) {
+ const singletonRootStore = rootStore ?? new RootStore();
+ // If your page has Next.js data fetching methods that use a Mobx store, it will
+ // get hydrated here, check `pages/ssg.js` and `pages/ssr.js` for more details
+ if (initialData) {
+ singletonRootStore.hydrate(initialData);
+ }
+ // For SSG and SSR always create a new store
+ if (typeof window === "undefined") return singletonRootStore;
+ // Create the store once in the client
+ if (!rootStore) rootStore = singletonRootStore;
+ return singletonRootStore;
+}
+
+export type StoreProviderProps = {
+ children: ReactNode;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ initialState?: any;
+};
+
+export const StoreProvider = ({ children, initialState = {} }: StoreProviderProps) => {
+ const store = initializeStore(initialState);
+ return {children};
+};
diff --git a/admin/lib/user-provider.tsx b/admin/lib/user-provider.tsx
new file mode 100644
index 000000000..d8448d13e
--- /dev/null
+++ b/admin/lib/user-provider.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import { FC, ReactNode, useEffect } from "react";
+import { observer } from "mobx-react-lite";
+import useSWR from "swr";
+// hooks
+import { useInstance, useTheme, useUser } from "@/hooks/store";
+
+interface IUserProvider {
+ children: ReactNode;
+}
+
+export const UserProvider: FC = observer(({ children }) => {
+ // hooks
+ const { isSidebarCollapsed, toggleSidebar } = useTheme();
+ const { currentUser, fetchCurrentUser } = useUser();
+ const { fetchInstanceAdmins } = useInstance();
+
+ useSWR("CURRENT_USER", () => fetchCurrentUser(), {
+ shouldRetryOnError: false,
+ });
+ useSWR("INSTANCE_ADMINS", () => fetchInstanceAdmins());
+
+ useEffect(() => {
+ const localValue = localStorage && localStorage.getItem("god_mode_sidebar_collapsed");
+ const localBoolValue = localValue ? (localValue === "true" ? true : false) : false;
+ if (isSidebarCollapsed === undefined && localBoolValue != isSidebarCollapsed) toggleSidebar(localBoolValue);
+ }, [isSidebarCollapsed, currentUser, toggleSidebar]);
+
+ return <>{children}>;
+});
diff --git a/admin/next.config.js b/admin/next.config.js
index 85b87e91f..07f6664af 100644
--- a/admin/next.config.js
+++ b/admin/next.config.js
@@ -7,7 +7,7 @@ const nextConfig = {
images: {
unoptimized: true,
},
- basePath: process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX === "1" ? "/god-mode" : "",
+ basePath: process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "",
};
module.exports = nextConfig;
diff --git a/admin/package.json b/admin/package.json
index e0913d094..1e1bc372e 100644
--- a/admin/package.json
+++ b/admin/package.json
@@ -1,18 +1,20 @@
{
"name": "admin",
- "version": "0.17.0",
+ "version": "0.20.0",
"private": true,
"scripts": {
"dev": "turbo run develop",
- "develop": "next dev --port 3333",
+ "develop": "next dev --port 3001",
"build": "next build",
"preview": "next build && next start",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
+ "@headlessui/react": "^1.7.19",
"@plane/types": "*",
"@plane/ui": "*",
+ "@plane/constants": "*",
"@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.17.0",
"autoprefixer": "10.4.14",
@@ -22,11 +24,11 @@
"lucide-react": "^0.356.0",
"mobx": "^6.12.0",
"mobx-react-lite": "^4.0.5",
- "next": "^14.1.0",
+ "next": "^14.2.3",
"next-themes": "^0.2.1",
- "postcss": "8.4.23",
- "react": "^18.2.0",
- "react-dom": "^18.2.0",
+ "postcss": "^8.4.38",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
"react-hook-form": "^7.51.0",
"swr": "^2.2.4",
"tailwindcss": "3.3.2",
diff --git a/admin/public/auth/background-pattern-dark.svg b/admin/public/auth/background-pattern-dark.svg
new file mode 100644
index 000000000..c258cbabf
--- /dev/null
+++ b/admin/public/auth/background-pattern-dark.svg
@@ -0,0 +1,68 @@
+
diff --git a/admin/public/auth/background-pattern.svg b/admin/public/auth/background-pattern.svg
new file mode 100644
index 000000000..5fcbeec27
--- /dev/null
+++ b/admin/public/auth/background-pattern.svg
@@ -0,0 +1,68 @@
+
diff --git a/admin/public/favicon/android-chrome-192x192.png b/admin/public/favicon/android-chrome-192x192.png
new file mode 100644
index 000000000..62e95acfc
Binary files /dev/null and b/admin/public/favicon/android-chrome-192x192.png differ
diff --git a/admin/public/favicon/android-chrome-512x512.png b/admin/public/favicon/android-chrome-512x512.png
new file mode 100644
index 000000000..41400832b
Binary files /dev/null and b/admin/public/favicon/android-chrome-512x512.png differ
diff --git a/admin/public/favicon/apple-touch-icon.png b/admin/public/favicon/apple-touch-icon.png
new file mode 100644
index 000000000..5273d4951
Binary files /dev/null and b/admin/public/favicon/apple-touch-icon.png differ
diff --git a/admin/public/favicon/favicon-16x16.png b/admin/public/favicon/favicon-16x16.png
new file mode 100644
index 000000000..8ddbd49c0
Binary files /dev/null and b/admin/public/favicon/favicon-16x16.png differ
diff --git a/admin/public/favicon/favicon-32x32.png b/admin/public/favicon/favicon-32x32.png
new file mode 100644
index 000000000..80cbe7a68
Binary files /dev/null and b/admin/public/favicon/favicon-32x32.png differ
diff --git a/admin/public/favicon/favicon.ico b/admin/public/favicon/favicon.ico
new file mode 100644
index 000000000..9094a07c7
Binary files /dev/null and b/admin/public/favicon/favicon.ico differ
diff --git a/admin/public/favicon/site.webmanifest b/admin/public/favicon/site.webmanifest
new file mode 100644
index 000000000..0b08af126
--- /dev/null
+++ b/admin/public/favicon/site.webmanifest
@@ -0,0 +1,11 @@
+{
+ "name": "",
+ "short_name": "",
+ "icons": [
+ { "src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
+ { "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" }
+ ],
+ "theme_color": "#ffffff",
+ "background_color": "#ffffff",
+ "display": "standalone"
+}
diff --git a/admin/public/images/logo-spinner-dark.gif b/admin/public/images/logo-spinner-dark.gif
new file mode 100644
index 000000000..4e0a1deb7
Binary files /dev/null and b/admin/public/images/logo-spinner-dark.gif differ
diff --git a/admin/public/images/logo-spinner-light.gif b/admin/public/images/logo-spinner-light.gif
new file mode 100644
index 000000000..7c9bfbe0e
Binary files /dev/null and b/admin/public/images/logo-spinner-light.gif differ
diff --git a/admin/public/instance/instance-failure-dark.svg b/admin/public/instance/instance-failure-dark.svg
new file mode 100644
index 000000000..58d691705
--- /dev/null
+++ b/admin/public/instance/instance-failure-dark.svg
@@ -0,0 +1,40 @@
+
diff --git a/admin/public/instance/instance-failure.svg b/admin/public/instance/instance-failure.svg
new file mode 100644
index 000000000..a59862283
--- /dev/null
+++ b/admin/public/instance/instance-failure.svg
@@ -0,0 +1,40 @@
+
diff --git a/admin/public/instance/plane-takeoff.png b/admin/public/instance/plane-takeoff.png
new file mode 100644
index 000000000..417ff8299
Binary files /dev/null and b/admin/public/instance/plane-takeoff.png differ
diff --git a/admin/public/site.webmanifest.json b/admin/public/site.webmanifest.json
new file mode 100644
index 000000000..6e5e438f8
--- /dev/null
+++ b/admin/public/site.webmanifest.json
@@ -0,0 +1,13 @@
+{
+ "name": "Plane God Mode",
+ "short_name": "Plane God Mode",
+ "description": "Plane helps you plan your issues, cycles, and product modules.",
+ "start_url": ".",
+ "display": "standalone",
+ "background_color": "#f9fafb",
+ "theme_color": "#3f76ff",
+ "icons": [
+ { "src": "/favicon/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
+ { "src": "/favicon/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" }
+ ]
+}
diff --git a/admin/services/api.service.ts b/admin/services/api.service.ts
index 5de7196aa..fa45c10b7 100644
--- a/admin/services/api.service.ts
+++ b/admin/services/api.service.ts
@@ -1,4 +1,6 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
+// store
+// import { rootStore } from "@/lib/store-context";
export abstract class APIService {
protected baseURL: string;
@@ -15,13 +17,14 @@ export abstract class APIService {
}
private setupInterceptors() {
- this.axiosInstance.interceptors.response.use(
- (response) => response,
- (error) => {
- if (error.response && error.response.status === 401) window.location.href = "/login";
- return Promise.reject(error.response?.data ?? error);
- }
- );
+ // this.axiosInstance.interceptors.response.use(
+ // (response) => response,
+ // (error) => {
+ // const store = rootStore;
+ // if (error.response && error.response.status === 401 && store.user.currentUser) store.user.reset();
+ // return Promise.reject(error);
+ // }
+ // );
}
get(url: string, params = {}): Promise> {
diff --git a/admin/services/auth.service.ts b/admin/services/auth.service.ts
index c67db9cb6..ef7b7b151 100644
--- a/admin/services/auth.service.ts
+++ b/admin/services/auth.service.ts
@@ -1,7 +1,7 @@
-// services
-import { APIService } from "services/api.service";
// helpers
import { API_BASE_URL } from "helpers/common.helper";
+// services
+import { APIService } from "services/api.service";
type TCsrfTokenResponse = {
csrf_token: string;
@@ -19,27 +19,4 @@ export class AuthService extends APIService {
throw error;
});
}
-
- async signOut(baseUrl: string): Promise {
- await this.requestCSRFToken().then((data) => {
- const csrfToken = data?.csrf_token;
-
- if (!csrfToken) throw Error("CSRF token not found");
-
- var form = document.createElement("form");
- var element1 = document.createElement("input");
-
- form.method = "POST";
- form.action = `${baseUrl}/api/instances/admins/sign-out/`;
-
- element1.value = csrfToken;
- element1.name = "csrfmiddlewaretoken";
- element1.type = "hidden";
- form.appendChild(element1);
-
- document.body.appendChild(form);
-
- form.submit();
- });
- }
}
diff --git a/admin/services/instance.service.ts b/admin/services/instance.service.ts
index 519adc9f2..feb94ceea 100644
--- a/admin/services/instance.service.ts
+++ b/admin/services/instance.service.ts
@@ -1,19 +1,25 @@
-import { APIService } from "services/api.service";
// types
-import type { IFormattedInstanceConfiguration, IInstance, IInstanceAdmin, IInstanceConfiguration } from "@plane/types";
+import type {
+ IFormattedInstanceConfiguration,
+ IInstance,
+ IInstanceAdmin,
+ IInstanceConfiguration,
+ IInstanceInfo,
+} from "@plane/types";
// helpers
-import { API_BASE_URL } from "helpers/common.helper";
+import { API_BASE_URL } from "@/helpers/common.helper";
+import { APIService } from "@/services/api.service";
export class InstanceService extends APIService {
constructor() {
super(API_BASE_URL);
}
- async getInstanceInfo(): Promise {
- return this.get("/api/instances/")
+ async getInstanceInfo(): Promise {
+ return this.get("/api/instances/")
.then((response) => response.data)
.catch((error) => {
- throw error;
+ throw error?.response?.data;
});
}
@@ -25,8 +31,8 @@ export class InstanceService extends APIService {
});
}
- async updateInstanceInfo(data: Partial): Promise {
- return this.patch, IInstance["instance"]>("/api/instances/", data)
+ async updateInstanceInfo(data: Partial): Promise {
+ return this.patch, IInstance>("/api/instances/", data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
diff --git a/admin/services/user.service.ts b/admin/services/user.service.ts
index 9209ec460..bef384daf 100644
--- a/admin/services/user.service.ts
+++ b/admin/services/user.service.ts
@@ -1,15 +1,25 @@
+// helpers
+import { API_BASE_URL } from "helpers/common.helper";
// services
import { APIService } from "services/api.service";
// types
import type { IUser } from "@plane/types";
-// helpers
-import { API_BASE_URL } from "helpers/common.helper";
+
+interface IUserSession extends IUser {
+ isAuthenticated: boolean;
+}
export class UserService extends APIService {
constructor() {
super(API_BASE_URL);
}
+ async authCheck(): Promise {
+ return this.get("/api/instances/admins/me/")
+ .then((response) => ({ ...response?.data, isAuthenticated: true }))
+ .catch(() => ({ isAuthenticated: false }));
+ }
+
async currentUser(): Promise {
return this.get("/api/instances/admins/me/")
.then((response) => response?.data)
diff --git a/admin/store/instance.store.ts b/admin/store/instance.store.ts
index fdc46e99b..a99cd808c 100644
--- a/admin/store/instance.store.ts
+++ b/admin/store/instance.store.ts
@@ -1,34 +1,46 @@
-import { observable, action, computed, makeObservable, runInAction } from "mobx";
import set from "lodash/set";
-import { IInstance, IInstanceAdmin, IInstanceConfiguration, IFormattedInstanceConfiguration } from "@plane/types";
+import { observable, action, computed, makeObservable, runInAction } from "mobx";
+import {
+ IInstance,
+ IInstanceAdmin,
+ IInstanceConfiguration,
+ IFormattedInstanceConfiguration,
+ IInstanceInfo,
+ IInstanceConfig,
+} from "@plane/types";
// helpers
import { EInstanceStatus, TInstanceStatus } from "@/helpers";
// services
import { InstanceService } from "@/services/instance.service";
// root store
-import { RootStore } from "@/store/root-store";
+import { RootStore } from "@/store/root.store";
export interface IInstanceStore {
// issues
isLoading: boolean;
+ error: any;
instanceStatus: TInstanceStatus | undefined;
instance: IInstance | undefined;
+ config: IInstanceConfig | undefined;
instanceAdmins: IInstanceAdmin[] | undefined;
instanceConfigurations: IInstanceConfiguration[] | undefined;
// computed
formattedConfig: IFormattedInstanceConfiguration | undefined;
// action
- fetchInstanceInfo: () => Promise;
- updateInstanceInfo: (data: Partial) => Promise;
+ hydrate: (data: IInstanceInfo) => void;
+ fetchInstanceInfo: () => Promise;
+ updateInstanceInfo: (data: Partial) => Promise;
fetchInstanceAdmins: () => Promise;
fetchInstanceConfigurations: () => Promise;
- updateInstanceConfigurations: (data: Partial) => Promise;
+ updateInstanceConfigurations: (data: Partial) => Promise;
}
export class InstanceStore implements IInstanceStore {
isLoading: boolean = true;
+ error: any = undefined;
instanceStatus: TInstanceStatus | undefined = undefined;
instance: IInstance | undefined = undefined;
+ config: IInstanceConfig | undefined = undefined;
instanceAdmins: IInstanceAdmin[] | undefined = undefined;
instanceConfigurations: IInstanceConfiguration[] | undefined = undefined;
// service
@@ -38,6 +50,7 @@ export class InstanceStore implements IInstanceStore {
makeObservable(this, {
// observable
isLoading: observable.ref,
+ error: observable.ref,
instanceStatus: observable,
instance: observable,
instanceAdmins: observable,
@@ -45,6 +58,7 @@ export class InstanceStore implements IInstanceStore {
// computed
formattedConfig: computed,
// actions
+ hydrate: action,
fetchInstanceInfo: action,
fetchInstanceAdmins: action,
updateInstanceInfo: action,
@@ -55,6 +69,13 @@ export class InstanceStore implements IInstanceStore {
this.instanceService = new InstanceService();
}
+ hydrate = (data: IInstanceInfo) => {
+ if (data) {
+ this.instance = data.instance;
+ this.config = data.config;
+ }
+ };
+
/**
* computed value for instance configurations data for forms.
* @returns configurations in the form of {key, value} pair.
@@ -74,15 +95,22 @@ export class InstanceStore implements IInstanceStore {
fetchInstanceInfo = async () => {
try {
if (this.instance === undefined) this.isLoading = true;
- const instance = await this.instanceService.getInstanceInfo();
+ this.error = undefined;
+ const instanceInfo = await this.instanceService.getInstanceInfo();
+ // handling the new user popup toggle
+ if (this.instance === undefined && !instanceInfo?.instance?.workspaces_exist)
+ this.store.theme.toggleNewUserPopup();
runInAction(() => {
+ console.log("instanceInfo: ", instanceInfo);
this.isLoading = false;
- this.instance = instance;
+ this.instance = instanceInfo.instance;
+ this.config = instanceInfo.config;
});
- return instance;
+ return instanceInfo;
} catch (error) {
console.error("Error fetching the instance info");
this.isLoading = false;
+ this.error = { message: "Failed to fetch the instance info" };
this.instanceStatus = {
status: EInstanceStatus.ERROR,
};
@@ -92,10 +120,10 @@ export class InstanceStore implements IInstanceStore {
/**
* @description updating instance information
- * @param {Partial} data
+ * @param {Partial} data
* @returns void
*/
- updateInstanceInfo = async (data: Partial) => {
+ updateInstanceInfo = async (data: Partial) => {
try {
const instanceResponse = await this.instanceService.updateInstanceInfo(data);
if (instanceResponse) {
@@ -146,13 +174,15 @@ export class InstanceStore implements IInstanceStore {
*/
updateInstanceConfigurations = async (data: Partial) => {
try {
- await this.instanceService.updateInstanceConfigurations(data).then((response) => {
- runInAction(() => {
- this.instanceConfigurations = this.instanceConfigurations
- ? [...this.instanceConfigurations, ...response]
- : response;
+ const response = await this.instanceService.updateInstanceConfigurations(data);
+ runInAction(() => {
+ this.instanceConfigurations = this.instanceConfigurations?.map((config) => {
+ const item = response.find((item) => item.key === config.key);
+ if (item) return item;
+ return config;
});
});
+ return response;
} catch (error) {
console.error("Error updating the instance configurations");
throw error;
diff --git a/admin/store/root.store.ts b/admin/store/root.store.ts
new file mode 100644
index 000000000..553a22200
--- /dev/null
+++ b/admin/store/root.store.ts
@@ -0,0 +1,32 @@
+import { enableStaticRendering } from "mobx-react-lite";
+// stores
+import { IInstanceStore, InstanceStore } from "./instance.store";
+import { IThemeStore, ThemeStore } from "./theme.store";
+import { IUserStore, UserStore } from "./user.store";
+
+enableStaticRendering(typeof window === "undefined");
+
+export class RootStore {
+ theme: IThemeStore;
+ instance: IInstanceStore;
+ user: IUserStore;
+
+ constructor() {
+ this.theme = new ThemeStore(this);
+ this.instance = new InstanceStore(this);
+ this.user = new UserStore(this);
+ }
+
+ hydrate(initialData: any) {
+ this.theme.hydrate(initialData.theme);
+ this.instance.hydrate(initialData.instance);
+ this.user.hydrate(initialData.user);
+ }
+
+ resetOnSignOut() {
+ localStorage.setItem("theme", "system");
+ this.instance = new InstanceStore(this);
+ this.user = new UserStore(this);
+ this.theme = new ThemeStore(this);
+ }
+}
diff --git a/admin/store/theme.store.ts b/admin/store/theme.store.ts
index aa695f1cf..a3f3b3d5a 100644
--- a/admin/store/theme.store.ts
+++ b/admin/store/theme.store.ts
@@ -1,35 +1,50 @@
import { action, observable, makeObservable } from "mobx";
// root store
-import { RootStore } from "@/store/root-store";
+import { RootStore } from "@/store/root.store";
type TTheme = "dark" | "light";
export interface IThemeStore {
// observables
+ isNewUserPopup: boolean;
theme: string | undefined;
isSidebarCollapsed: boolean | undefined;
// actions
+ hydrate: (data: any) => void;
+ toggleNewUserPopup: () => void;
toggleSidebar: (collapsed: boolean) => void;
setTheme: (currentTheme: TTheme) => void;
}
export class ThemeStore implements IThemeStore {
// observables
+ isNewUserPopup: boolean = false;
isSidebarCollapsed: boolean | undefined = undefined;
theme: string | undefined = undefined;
constructor(private store: RootStore) {
makeObservable(this, {
// observables
+ isNewUserPopup: observable.ref,
isSidebarCollapsed: observable.ref,
theme: observable.ref,
// action
+ toggleNewUserPopup: action,
toggleSidebar: action,
setTheme: action,
});
}
+ hydrate = (data: any) => {
+ if (data) this.theme = data;
+ };
+
/**
- * Toggle the sidebar collapsed state
+ * @description Toggle the new user popup modal
+ */
+ toggleNewUserPopup = () => (this.isNewUserPopup = !this.isNewUserPopup);
+
+ /**
+ * @description Toggle the sidebar collapsed state
* @param isCollapsed
*/
toggleSidebar = (isCollapsed: boolean) => {
@@ -39,7 +54,7 @@ export class ThemeStore implements IThemeStore {
};
/**
- * Sets the user theme and applies it to the platform
+ * @description Sets the user theme and applies it to the platform
* @param currentTheme
*/
setTheme = async (currentTheme: TTheme) => {
diff --git a/admin/store/user.store.ts b/admin/store/user.store.ts
index 0a7895e7b..60638f0cd 100644
--- a/admin/store/user.store.ts
+++ b/admin/store/user.store.ts
@@ -3,11 +3,10 @@ import { IUser } from "@plane/types";
// helpers
import { EUserStatus, TUserStatus } from "@/helpers";
// services
-import { UserService } from "services/user.service";
+import { AuthService } from "@/services/auth.service";
+import { UserService } from "@/services/user.service";
// root store
-import { RootStore } from "@/store/root-store";
-import { AuthService } from "@/services";
-import { API_BASE_URL } from "@/helpers/common.helper";
+import { RootStore } from "@/store/root.store";
export interface IUserStore {
// observables
@@ -16,8 +15,10 @@ export interface IUserStore {
isUserLoggedIn: boolean | undefined;
currentUser: IUser | undefined;
// fetch actions
+ hydrate: (data: any) => void;
fetchCurrentUser: () => Promise;
- signOut: () => Promise;
+ reset: () => void;
+ signOut: () => void;
}
export class UserStore implements IUserStore {
@@ -29,8 +30,6 @@ export class UserStore implements IUserStore {
// services
userService;
authService;
- // rootStore
- rootStore;
constructor(private store: RootStore) {
makeObservable(this, {
@@ -41,12 +40,17 @@ export class UserStore implements IUserStore {
currentUser: observable,
// action
fetchCurrentUser: action,
+ reset: action,
+ signOut: action,
});
this.userService = new UserService();
this.authService = new AuthService();
- this.rootStore = store;
}
+ hydrate = (data: any) => {
+ if (data) this.currentUser = data;
+ };
+
/**
* @description Fetches the current user
* @returns Promise
@@ -55,11 +59,20 @@ export class UserStore implements IUserStore {
try {
if (this.currentUser === undefined) this.isLoading = true;
const currentUser = await this.userService.currentUser();
- runInAction(() => {
- this.isUserLoggedIn = true;
- this.currentUser = currentUser;
- this.isLoading = false;
- });
+ if (currentUser) {
+ await this.store.instance.fetchInstanceAdmins();
+ runInAction(() => {
+ this.isUserLoggedIn = true;
+ this.currentUser = currentUser;
+ this.isLoading = false;
+ });
+ } else {
+ runInAction(() => {
+ this.isUserLoggedIn = false;
+ this.currentUser = undefined;
+ this.isLoading = false;
+ });
+ }
return currentUser;
} catch (error: any) {
this.isLoading = false;
@@ -78,8 +91,14 @@ export class UserStore implements IUserStore {
}
};
+ reset = async () => {
+ this.isUserLoggedIn = false;
+ this.currentUser = undefined;
+ this.isLoading = false;
+ this.userStatus = undefined;
+ };
+
signOut = async () => {
- await this.authService.signOut(API_BASE_URL);
- this.rootStore.resetOnSignOut();
+ this.store.resetOnSignOut();
};
}
diff --git a/apiserver/.env.example b/apiserver/.env.example
index d8554f400..38944f79c 100644
--- a/apiserver/.env.example
+++ b/apiserver/.env.example
@@ -1,7 +1,7 @@
# Backend
# Debug value for api server use it as 0 for production use
DEBUG=0
-CORS_ALLOWED_ORIGINS=""
+CORS_ALLOWED_ORIGINS="http://localhost"
# Error logs
SENTRY_DSN=""
@@ -12,7 +12,8 @@ POSTGRES_USER="plane"
POSTGRES_PASSWORD="plane"
POSTGRES_HOST="plane-db"
POSTGRES_DB="plane"
-DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}/${POSTGRES_DB}
+POSTGRES_PORT=5432
+DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
# Redis Settings
@@ -44,3 +45,8 @@ WEB_URL="http://localhost"
# Gunicorn Workers
GUNICORN_WORKERS=2
+
+# Base URLs
+ADMIN_BASE_URL=
+SPACE_BASE_URL=
+APP_BASE_URL=
diff --git a/apiserver/package.json b/apiserver/package.json
index 2840f6bef..317e82033 100644
--- a/apiserver/package.json
+++ b/apiserver/package.json
@@ -1,4 +1,4 @@
{
"name": "plane-api",
- "version": "0.18.0"
+ "version": "0.20.0"
}
diff --git a/apiserver/plane/api/serializers/inbox.py b/apiserver/plane/api/serializers/inbox.py
index 78bb74d13..a0c79235d 100644
--- a/apiserver/plane/api/serializers/inbox.py
+++ b/apiserver/plane/api/serializers/inbox.py
@@ -1,9 +1,13 @@
# Module improts
from .base import BaseSerializer
+from .issue import IssueExpandSerializer
from plane.db.models import InboxIssue
class InboxIssueSerializer(BaseSerializer):
+
+ issue_detail = IssueExpandSerializer(read_only=True, source="issue")
+
class Meta:
model = InboxIssue
fields = "__all__"
diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py
index c40f56ccc..020917ee5 100644
--- a/apiserver/plane/api/serializers/issue.py
+++ b/apiserver/plane/api/serializers/issue.py
@@ -315,7 +315,7 @@ class IssueLinkSerializer(BaseSerializer):
if IssueLink.objects.filter(
url=validated_data.get("url"),
issue_id=instance.issue_id,
- ).exists():
+ ).exclude(pk=instance.id).exists():
raise serializers.ValidationError(
{"error": "URL already exists for this Issue"}
)
diff --git a/apiserver/plane/api/views/base.py b/apiserver/plane/api/views/base.py
index 13047eb78..fee508a30 100644
--- a/apiserver/plane/api/views/base.py
+++ b/apiserver/plane/api/views/base.py
@@ -1,6 +1,4 @@
# Python imports
-from urllib.parse import urlparse
-
import zoneinfo
# Django imports
@@ -19,7 +17,6 @@ from rest_framework.views import APIView
# Module imports
from plane.api.middleware.api_authentication import APIKeyAuthentication
from plane.api.rate_limit import ApiKeyRateThrottle
-from plane.bgtasks.webhook_task import send_webhook
from plane.utils.exception_logger import log_exception
from plane.utils.paginator import BasePaginator
@@ -38,40 +35,6 @@ class TimezoneMixin:
timezone.deactivate()
-class WebhookMixin:
- webhook_event = None
- bulk = False
-
- def finalize_response(self, request, response, *args, **kwargs):
- response = super().finalize_response(
- request, response, *args, **kwargs
- )
-
- # Check for the case should webhook be sent
- if (
- self.webhook_event
- and self.request.method in ["POST", "PATCH", "DELETE"]
- and response.status_code in [200, 201, 204]
- ):
- url = request.build_absolute_uri()
- parsed_url = urlparse(url)
- # Extract the scheme and netloc
- scheme = parsed_url.scheme
- netloc = parsed_url.netloc
- # Push the object to delay
- send_webhook.delay(
- event=self.webhook_event,
- payload=response.data,
- kw=self.kwargs,
- action=self.request.method,
- slug=self.workspace_slug,
- bulk=self.bulk,
- current_site=f"{scheme}://{netloc}",
- )
-
- return response
-
-
class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
authentication_classes = [
APIKeyAuthentication,
diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py
index d9c75ff41..6e1e5e057 100644
--- a/apiserver/plane/api/views/cycle.py
+++ b/apiserver/plane/api/views/cycle.py
@@ -5,6 +5,7 @@ import json
from django.core import serializers
from django.db.models import Count, F, Func, OuterRef, Q, Sum
from django.utils import timezone
+from django.core.serializers.json import DjangoJSONEncoder
# Third party imports
from rest_framework import status
@@ -26,10 +27,11 @@ from plane.db.models import (
)
from plane.utils.analytics_plot import burndown_plot
-from .base import BaseAPIView, WebhookMixin
+from .base import BaseAPIView
+from plane.bgtasks.webhook_task import model_activity
-class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
+class CycleAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to cycle.
@@ -277,6 +279,16 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
project_id=project_id,
owned_by=request.user,
)
+ # Send the model activity
+ model_activity.delay(
+ model_name="cycle",
+ model_id=str(serializer.data["id"]),
+ requested_data=request.data,
+ current_instance=None,
+ actor_id=request.user.id,
+ slug=slug,
+ origin=request.META.get("HTTP_ORIGIN"),
+ )
return Response(
serializer.data, status=status.HTTP_201_CREATED
)
@@ -295,6 +307,11 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
cycle = Cycle.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
+
+ current_instance = json.dumps(
+ CycleSerializer(cycle).data, cls=DjangoJSONEncoder
+ )
+
if cycle.archived_at:
return Response(
{"error": "Archived cycle cannot be edited"},
@@ -344,6 +361,17 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_409_CONFLICT,
)
serializer.save()
+
+ # Send the model activity
+ model_activity.delay(
+ model_name="cycle",
+ model_id=str(serializer.data["id"]),
+ requested_data=request.data,
+ current_instance=current_instance,
+ actor_id=request.user.id,
+ slug=slug,
+ origin=request.META.get("HTTP_ORIGIN"),
+ )
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -515,7 +543,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT)
-class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
+class CycleIssueAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`,
and `destroy` actions related to cycle issues.
diff --git a/apiserver/plane/api/views/inbox.py b/apiserver/plane/api/views/inbox.py
index 5e6e4a215..8987e4f63 100644
--- a/apiserver/plane/api/views/inbox.py
+++ b/apiserver/plane/api/views/inbox.py
@@ -154,6 +154,13 @@ class InboxIssueAPIEndpoint(BaseAPIView):
state=state,
)
+ # create an inbox issue
+ inbox_issue = InboxIssue.objects.create(
+ inbox_id=inbox.id,
+ project_id=project_id,
+ issue=issue,
+ source=request.data.get("source", "in-app"),
+ )
# Create an Issue Activity
issue_activity.delay(
type="issue.activity.created",
@@ -163,14 +170,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
- )
-
- # create an inbox issue
- inbox_issue = InboxIssue.objects.create(
- inbox_id=inbox.id,
- project_id=project_id,
- issue=issue,
- source=request.data.get("source", "in-app"),
+ inbox=str(inbox_issue.id),
)
serializer = InboxIssueSerializer(inbox_issue)
@@ -260,6 +260,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp()),
+ inbox=(inbox_issue.id),
)
issue_serializer.save()
else:
@@ -327,6 +328,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
epoch=int(timezone.now().timestamp()),
notification=False,
origin=request.META.get("HTTP_ORIGIN"),
+ inbox=str(inbox_issue.id),
)
return Response(serializer.data, status=status.HTTP_200_OK)
diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py
index 46a6b6937..a62278b19 100644
--- a/apiserver/plane/api/views/issue.py
+++ b/apiserver/plane/api/views/issue.py
@@ -32,7 +32,6 @@ from plane.api.serializers import (
LabelSerializer,
)
from plane.app.permissions import (
- WorkspaceEntityPermission,
ProjectEntityPermission,
ProjectLitePermission,
ProjectMemberPermission,
@@ -49,11 +48,10 @@ from plane.db.models import (
ProjectMember,
)
-from .base import BaseAPIView, WebhookMixin
+from .base import BaseAPIView
-
-class WorkspaceIssueAPIEndpoint(WebhookMixin, BaseAPIView):
+class WorkspaceIssueAPIEndpoint(BaseAPIView):
"""
This viewset provides `retrieveByIssueId` on workspace level
@@ -61,12 +59,9 @@ class WorkspaceIssueAPIEndpoint(WebhookMixin, BaseAPIView):
model = Issue
webhook_event = "issue"
- permission_classes = [
- ProjectEntityPermission
- ]
+ permission_classes = [ProjectEntityPermission]
serializer_class = IssueSerializer
-
@property
def project__identifier(self):
return self.kwargs.get("project__identifier", None)
@@ -92,7 +87,9 @@ class WorkspaceIssueAPIEndpoint(WebhookMixin, BaseAPIView):
.order_by(self.kwargs.get("order_by", "-created_at"))
).distinct()
- def get(self, request, slug, project__identifier=None, issue__identifier=None):
+ def get(
+ self, request, slug, project__identifier=None, issue__identifier=None
+ ):
if issue__identifier and project__identifier:
issue = Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(
@@ -101,7 +98,11 @@ class WorkspaceIssueAPIEndpoint(WebhookMixin, BaseAPIView):
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
- ).get(workspace__slug=slug, project__identifier=project__identifier, sequence_id=issue__identifier)
+ ).get(
+ workspace__slug=slug,
+ project__identifier=project__identifier,
+ sequence_id=issue__identifier,
+ )
return Response(
IssueSerializer(
issue,
@@ -111,7 +112,8 @@ class WorkspaceIssueAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_200_OK,
)
-class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
+
+class IssueAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to issue.
@@ -653,7 +655,7 @@ class IssueLinkAPIEndpoint(BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT)
-class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
+class IssueCommentAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to comments of the particular issue.
diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py
index 38744eaa5..eeb29dad2 100644
--- a/apiserver/plane/api/views/module.py
+++ b/apiserver/plane/api/views/module.py
@@ -5,6 +5,7 @@ import json
from django.core import serializers
from django.db.models import Count, F, Func, OuterRef, Prefetch, Q
from django.utils import timezone
+from django.core.serializers.json import DjangoJSONEncoder
# Third party imports
from rest_framework import status
@@ -28,10 +29,11 @@ from plane.db.models import (
Project,
)
-from .base import BaseAPIView, WebhookMixin
+from .base import BaseAPIView
+from plane.bgtasks.webhook_task import model_activity
-class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
+class ModuleAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to module.
@@ -163,6 +165,16 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_409_CONFLICT,
)
serializer.save()
+ # Send the model activity
+ model_activity.delay(
+ model_name="module",
+ model_id=str(serializer.data["id"]),
+ requested_data=request.data,
+ current_instance=None,
+ actor_id=request.user.id,
+ slug=slug,
+ origin=request.META.get("HTTP_ORIGIN"),
+ )
module = Module.objects.get(pk=serializer.data["id"])
serializer = ModuleSerializer(module)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@@ -172,6 +184,11 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
module = Module.objects.get(
pk=pk, project_id=project_id, workspace__slug=slug
)
+
+ current_instance = json.dumps(
+ ModuleSerializer(module).data, cls=DjangoJSONEncoder
+ )
+
if module.archived_at:
return Response(
{"error": "Archived module cannot be edited"},
@@ -204,6 +221,18 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_409_CONFLICT,
)
serializer.save()
+
+ # Send the model activity
+ model_activity.delay(
+ model_name="module",
+ model_id=str(serializer.data["id"]),
+ requested_data=request.data,
+ current_instance=current_instance,
+ actor_id=request.user.id,
+ slug=slug,
+ origin=request.META.get("HTTP_ORIGIN"),
+ )
+
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -260,7 +289,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT)
-class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
+class ModuleIssueAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to module issues.
diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py
index fcb0cc4fb..019ab704e 100644
--- a/apiserver/plane/api/views/project.py
+++ b/apiserver/plane/api/views/project.py
@@ -1,7 +1,11 @@
+# Python imports
+import json
+
# Django imports
from django.db import IntegrityError
from django.db.models import Exists, F, Func, OuterRef, Prefetch, Q, Subquery
from django.utils import timezone
+from django.core.serializers.json import DjangoJSONEncoder
# Third party imports
from rest_framework import status
@@ -23,11 +27,11 @@ from plane.db.models import (
State,
Workspace,
)
-
-from .base import BaseAPIView, WebhookMixin
+from plane.bgtasks.webhook_task import model_activity
+from .base import BaseAPIView
-class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
+class ProjectAPIEndpoint(BaseAPIView):
"""Project Endpoints to create, update, list, retrieve and delete endpoint"""
serializer_class = ProjectSerializer
@@ -236,6 +240,17 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
.filter(pk=serializer.data["id"])
.first()
)
+ # Model activity
+ model_activity.delay(
+ model_name="project",
+ model_id=str(project.id),
+ requested_data=request.data,
+ current_instance=None,
+ actor_id=request.user.id,
+ slug=slug,
+ origin=request.META.get("HTTP_ORIGIN"),
+ )
+
serializer = ProjectSerializer(project)
return Response(
serializer.data, status=status.HTTP_201_CREATED
@@ -265,7 +280,9 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
try:
workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(pk=pk)
-
+ current_instance = json.dumps(
+ ProjectSerializer(project).data, cls=DjangoJSONEncoder
+ )
if project.archived_at:
return Response(
{"error": "Archived project cannot be updated"},
@@ -303,6 +320,17 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
.filter(pk=serializer.data["id"])
.first()
)
+
+ model_activity.delay(
+ model_name="project",
+ model_id=str(project.id),
+ requested_data=request.data,
+ current_instance=current_instance,
+ actor_id=request.user.id,
+ slug=slug,
+ origin=request.META.get("HTTP_ORIGIN"),
+ )
+
serializer = ProjectSerializer(project)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(
diff --git a/apiserver/plane/api/views/state.py b/apiserver/plane/api/views/state.py
index 024a12d07..dd239754c 100644
--- a/apiserver/plane/api/views/state.py
+++ b/apiserver/plane/api/views/state.py
@@ -138,7 +138,7 @@ class StateAPIEndpoint(BaseAPIView):
serializer = StateSerializer(state, data=request.data, partial=True)
if serializer.is_valid():
if (
- str(request.data.get("external_id"))
+ request.data.get("external_id")
and (state.external_id != str(request.data.get("external_id")))
and State.objects.filter(
project_id=project_id,
diff --git a/apiserver/plane/app/serializers/__init__.py b/apiserver/plane/app/serializers/__init__.py
index 813c1af21..bdcdf6c0d 100644
--- a/apiserver/plane/app/serializers/__init__.py
+++ b/apiserver/plane/app/serializers/__init__.py
@@ -28,7 +28,6 @@ from .project import (
ProjectMemberSerializer,
ProjectMemberInviteSerializer,
ProjectIdentifierSerializer,
- ProjectFavoriteSerializer,
ProjectLiteSerializer,
ProjectMemberLiteSerializer,
ProjectDeployBoardSerializer,
@@ -40,12 +39,10 @@ from .state import StateSerializer, StateLiteSerializer
from .view import (
GlobalViewSerializer,
IssueViewSerializer,
- IssueViewFavoriteSerializer,
)
from .cycle import (
CycleSerializer,
CycleIssueSerializer,
- CycleFavoriteSerializer,
CycleWriteSerializer,
CycleUserPropertiesSerializer,
)
@@ -83,7 +80,6 @@ from .module import (
ModuleSerializer,
ModuleIssueSerializer,
ModuleLinkSerializer,
- ModuleFavoriteSerializer,
ModuleUserPropertiesSerializer,
)
@@ -96,7 +92,6 @@ from .page import (
PageLogSerializer,
SubPageSerializer,
PageDetailSerializer,
- PageFavoriteSerializer,
)
from .estimate import (
diff --git a/apiserver/plane/app/serializers/cycle.py b/apiserver/plane/app/serializers/cycle.py
index 13d321780..1a9ce52d1 100644
--- a/apiserver/plane/app/serializers/cycle.py
+++ b/apiserver/plane/app/serializers/cycle.py
@@ -7,7 +7,6 @@ from .issue import IssueStateSerializer
from plane.db.models import (
Cycle,
CycleIssue,
- CycleFavorite,
CycleUserProperties,
)
@@ -93,20 +92,6 @@ class CycleIssueSerializer(BaseSerializer):
"cycle",
]
-
-class CycleFavoriteSerializer(BaseSerializer):
- cycle_detail = CycleSerializer(source="cycle", read_only=True)
-
- class Meta:
- model = CycleFavorite
- fields = "__all__"
- read_only_fields = [
- "workspace",
- "project",
- "user",
- ]
-
-
class CycleUserPropertiesSerializer(BaseSerializer):
class Meta:
model = CycleUserProperties
diff --git a/apiserver/plane/app/serializers/issue.py b/apiserver/plane/app/serializers/issue.py
index 8c641b720..e4a04fadf 100644
--- a/apiserver/plane/app/serializers/issue.py
+++ b/apiserver/plane/app/serializers/issue.py
@@ -442,7 +442,7 @@ class IssueLinkSerializer(BaseSerializer):
raise serializers.ValidationError("Invalid URL format.")
# Check URL scheme
- if not value.startswith(('http://', 'https://')):
+ if not value.startswith(("http://", "https://")):
raise serializers.ValidationError("Invalid URL scheme.")
return value
@@ -462,7 +462,7 @@ class IssueLinkSerializer(BaseSerializer):
if IssueLink.objects.filter(
url=validated_data.get("url"),
issue_id=instance.issue_id,
- ).exists():
+ ).exclude(pk=instance.id).exists():
raise serializers.ValidationError(
{"error": "URL already exists for this Issue"}
)
@@ -636,6 +636,7 @@ class IssueInboxSerializer(DynamicBaseSerializer):
"project_id",
"created_at",
"label_ids",
+ "created_by",
]
read_only_fields = fields
diff --git a/apiserver/plane/app/serializers/module.py b/apiserver/plane/app/serializers/module.py
index 687747242..6a0c4c94f 100644
--- a/apiserver/plane/app/serializers/module.py
+++ b/apiserver/plane/app/serializers/module.py
@@ -11,7 +11,6 @@ from plane.db.models import (
ModuleMember,
ModuleIssue,
ModuleLink,
- ModuleFavorite,
ModuleUserProperties,
)
@@ -223,19 +222,6 @@ class ModuleDetailSerializer(ModuleSerializer):
fields = ModuleSerializer.Meta.fields + ["link_module", "sub_issues"]
-class ModuleFavoriteSerializer(BaseSerializer):
- module_detail = ModuleFlatSerializer(source="module", read_only=True)
-
- class Meta:
- model = ModuleFavorite
- fields = "__all__"
- read_only_fields = [
- "workspace",
- "project",
- "user",
- ]
-
-
class ModuleUserPropertiesSerializer(BaseSerializer):
class Meta:
model = ModuleUserProperties
diff --git a/apiserver/plane/app/serializers/page.py b/apiserver/plane/app/serializers/page.py
index 604ac2c2e..4f3cde39b 100644
--- a/apiserver/plane/app/serializers/page.py
+++ b/apiserver/plane/app/serializers/page.py
@@ -6,7 +6,6 @@ from .base import BaseSerializer
from plane.db.models import (
Page,
PageLog,
- PageFavorite,
PageLabel,
Label,
)
@@ -141,17 +140,4 @@ class PageLogSerializer(BaseSerializer):
"workspace",
"project",
"page",
- ]
-
-
-class PageFavoriteSerializer(BaseSerializer):
- page_detail = PageSerializer(source="page", read_only=True)
-
- class Meta:
- model = PageFavorite
- fields = "__all__"
- read_only_fields = [
- "workspace",
- "project",
- "user",
- ]
+ ]
\ No newline at end of file
diff --git a/apiserver/plane/app/serializers/project.py b/apiserver/plane/app/serializers/project.py
index a0c2318e3..96d92f340 100644
--- a/apiserver/plane/app/serializers/project.py
+++ b/apiserver/plane/app/serializers/project.py
@@ -13,7 +13,6 @@ from plane.db.models import (
ProjectMember,
ProjectMemberInvite,
ProjectIdentifier,
- ProjectFavorite,
ProjectDeployBoard,
ProjectPublicMember,
)
@@ -197,16 +196,6 @@ class ProjectIdentifierSerializer(BaseSerializer):
fields = "__all__"
-class ProjectFavoriteSerializer(BaseSerializer):
- class Meta:
- model = ProjectFavorite
- fields = "__all__"
- read_only_fields = [
- "workspace",
- "user",
- ]
-
-
class ProjectMemberLiteSerializer(BaseSerializer):
member = UserLiteSerializer(read_only=True)
is_subscribed = serializers.BooleanField(read_only=True)
diff --git a/apiserver/plane/app/serializers/view.py b/apiserver/plane/app/serializers/view.py
index f864f2b6c..c46a545d0 100644
--- a/apiserver/plane/app/serializers/view.py
+++ b/apiserver/plane/app/serializers/view.py
@@ -5,7 +5,7 @@ from rest_framework import serializers
from .base import BaseSerializer, DynamicBaseSerializer
from .workspace import WorkspaceLiteSerializer
from .project import ProjectLiteSerializer
-from plane.db.models import GlobalView, IssueView, IssueViewFavorite
+from plane.db.models import GlobalView, IssueView
from plane.utils.issue_filters import issue_filters
@@ -72,16 +72,3 @@ class IssueViewSerializer(DynamicBaseSerializer):
validated_data["query"] = {}
validated_data["query"] = issue_filters(query_params, "PATCH")
return super().update(instance, validated_data)
-
-
-class IssueViewFavoriteSerializer(BaseSerializer):
- view_detail = IssueViewSerializer(source="issue_view", read_only=True)
-
- class Meta:
- model = IssueViewFavorite
- fields = "__all__"
- read_only_fields = [
- "workspace",
- "project",
- "user",
- ]
diff --git a/apiserver/plane/app/urls/user.py b/apiserver/plane/app/urls/user.py
index c069467a2..fd18ea87b 100644
--- a/apiserver/plane/app/urls/user.py
+++ b/apiserver/plane/app/urls/user.py
@@ -11,6 +11,7 @@ from plane.app.views import (
UserEndpoint,
UserIssueCompletedGraphEndpoint,
UserWorkspaceDashboardEndpoint,
+ UserSessionEndpoint,
## End User
## Workspaces
UserWorkSpacesEndpoint,
@@ -29,6 +30,11 @@ urlpatterns = [
),
name="users",
),
+ path(
+ "users/session/",
+ UserSessionEndpoint.as_view(),
+ name="user-session",
+ ),
path(
"users/me/settings/",
UserEndpoint.as_view(
diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py
index 0268f673e..bf765e719 100644
--- a/apiserver/plane/app/views/__init__.py
+++ b/apiserver/plane/app/views/__init__.py
@@ -29,7 +29,7 @@ from .user.base import (
)
-from .base import BaseAPIView, BaseViewSet, WebhookMixin
+from .base import BaseAPIView, BaseViewSet
from .workspace.base import (
WorkSpaceViewSet,
@@ -222,4 +222,4 @@ from .error_404 import custom_404_view
from .exporter.base import ExportIssuesEndpoint
from .notification.base import MarkAllReadNotificationViewSet
-from .user.base import AccountEndpoint, ProfileEndpoint
+from .user.base import AccountEndpoint, ProfileEndpoint, UserSessionEndpoint
diff --git a/apiserver/plane/app/views/base.py b/apiserver/plane/app/views/base.py
index 42cac04fb..8f21f5fe1 100644
--- a/apiserver/plane/app/views/base.py
+++ b/apiserver/plane/app/views/base.py
@@ -20,7 +20,6 @@ from rest_framework.viewsets import ModelViewSet
# Module imports
from plane.authentication.session import BaseSessionAuthentication
-from plane.bgtasks.webhook_task import send_webhook
from plane.utils.exception_logger import log_exception
from plane.utils.paginator import BasePaginator
@@ -39,35 +38,6 @@ class TimezoneMixin:
timezone.deactivate()
-class WebhookMixin:
- webhook_event = None
- bulk = False
-
- def finalize_response(self, request, response, *args, **kwargs):
- response = super().finalize_response(
- request, response, *args, **kwargs
- )
-
- # Check for the case should webhook be sent
- if (
- self.webhook_event
- and self.request.method in ["POST", "PATCH", "DELETE"]
- and response.status_code in [200, 201, 204]
- ):
- # Push the object to delay
- send_webhook.delay(
- event=self.webhook_event,
- payload=response.data,
- kw=self.kwargs,
- action=self.request.method,
- slug=self.workspace_slug,
- bulk=self.bulk,
- current_site=request.META.get("HTTP_ORIGIN"),
- )
-
- return response
-
-
class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
model = None
diff --git a/apiserver/plane/app/views/cycle/archive.py b/apiserver/plane/app/views/cycle/archive.py
index e6d82795a..5e1241b08 100644
--- a/apiserver/plane/app/views/cycle/archive.py
+++ b/apiserver/plane/app/views/cycle/archive.py
@@ -24,7 +24,7 @@ from rest_framework.response import Response
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import (
Cycle,
- CycleFavorite,
+ UserFavorite,
Issue,
Label,
User,
@@ -42,9 +42,10 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
]
def get_queryset(self):
- favorite_subquery = CycleFavorite.objects.filter(
+ favorite_subquery = UserFavorite.objects.filter(
user=self.request.user,
- cycle_id=OuterRef("pk"),
+ entity_type="cycle",
+ entity_identifier=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py
index dd9826c56..e0b28ac7b 100644
--- a/apiserver/plane/app/views/cycle/base.py
+++ b/apiserver/plane/app/views/cycle/base.py
@@ -20,6 +20,7 @@ from django.db.models import (
)
from django.db.models.functions import Coalesce
from django.utils import timezone
+from django.core.serializers.json import DjangoJSONEncoder
# Third party imports
from rest_framework import status
@@ -29,7 +30,6 @@ from plane.app.permissions import (
ProjectLitePermission,
)
from plane.app.serializers import (
- CycleFavoriteSerializer,
CycleSerializer,
CycleUserPropertiesSerializer,
CycleWriteSerializer,
@@ -37,8 +37,8 @@ from plane.app.serializers import (
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Cycle,
- CycleFavorite,
CycleIssue,
+ UserFavorite,
CycleUserProperties,
Issue,
Label,
@@ -47,10 +47,11 @@ from plane.db.models import (
from plane.utils.analytics_plot import burndown_plot
# Module imports
-from .. import BaseAPIView, BaseViewSet, WebhookMixin
+from .. import BaseAPIView, BaseViewSet
+from plane.bgtasks.webhook_task import model_activity
-class CycleViewSet(WebhookMixin, BaseViewSet):
+class CycleViewSet(BaseViewSet):
serializer_class = CycleSerializer
model = Cycle
webhook_event = "cycle"
@@ -65,9 +66,10 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
)
def get_queryset(self):
- favorite_subquery = CycleFavorite.objects.filter(
+ favorite_subquery = UserFavorite.objects.filter(
user=self.request.user,
- cycle_id=OuterRef("pk"),
+ entity_identifier=OuterRef("pk"),
+ entity_type="cycle",
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
@@ -239,6 +241,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
"backlog_issues",
"assignee_ids",
"status",
+ "created_by",
)
if data:
@@ -363,6 +366,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
"backlog_issues",
"assignee_ids",
"status",
+ "created_by",
)
return Response(data, status=status.HTTP_200_OK)
@@ -412,6 +416,17 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
)
.first()
)
+
+ # Send the model activity
+ model_activity.delay(
+ model_name="cycle",
+ model_id=str(cycle["id"]),
+ requested_data=request.data,
+ current_instance=None,
+ actor_id=request.user.id,
+ slug=slug,
+ origin=request.META.get("HTTP_ORIGIN"),
+ )
return Response(cycle, status=status.HTTP_201_CREATED)
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
@@ -434,6 +449,11 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
{"error": "Archived cycle cannot be updated"},
status=status.HTTP_400_BAD_REQUEST,
)
+
+ current_instance = json.dumps(
+ CycleSerializer(cycle).data, cls=DjangoJSONEncoder
+ )
+
request_data = request.data
if (
@@ -487,6 +507,18 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
"assignee_ids",
"status",
).first()
+
+ # Send the model activity
+ model_activity.delay(
+ model_name="cycle",
+ model_id=str(cycle["id"]),
+ requested_data=request.data,
+ current_instance=current_instance,
+ actor_id=request.user.id,
+ slug=slug,
+ origin=request.META.get("HTTP_ORIGIN"),
+ )
+
return Response(cycle, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -534,6 +566,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
"backlog_issues",
"assignee_ids",
"status",
+ "created_by",
)
.first()
)
@@ -721,8 +754,7 @@ class CycleDateCheckEndpoint(BaseAPIView):
class CycleFavoriteViewSet(BaseViewSet):
- serializer_class = CycleFavoriteSerializer
- model = CycleFavorite
+ model = UserFavorite
def get_queryset(self):
return self.filter_queryset(
@@ -734,18 +766,21 @@ class CycleFavoriteViewSet(BaseViewSet):
)
def create(self, request, slug, project_id):
- serializer = CycleFavoriteSerializer(data=request.data)
- if serializer.is_valid():
- serializer.save(user=request.user, project_id=project_id)
- return Response(serializer.data, status=status.HTTP_201_CREATED)
- return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+ _ = UserFavorite.objects.create(
+ project_id=project_id,
+ user=request.user,
+ entity_type="cycle",
+ entity_identifier=request.data.get("cycle"),
+ )
+ return Response(status=status.HTTP_204_NO_CONTENT)
def destroy(self, request, slug, project_id, cycle_id):
- cycle_favorite = CycleFavorite.objects.get(
+ cycle_favorite = UserFavorite.objects.get(
project=project_id,
+ entity_type="cycle",
user=request.user,
workspace__slug=slug,
- cycle_id=cycle_id,
+ entity_identifier=cycle_id,
)
cycle_favorite.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
diff --git a/apiserver/plane/app/views/cycle/issue.py b/apiserver/plane/app/views/cycle/issue.py
index 2a5505dd0..fdc998f6d 100644
--- a/apiserver/plane/app/views/cycle/issue.py
+++ b/apiserver/plane/app/views/cycle/issue.py
@@ -23,7 +23,7 @@ from rest_framework.response import Response
from rest_framework import status
# Module imports
-from .. import BaseViewSet, WebhookMixin
+from .. import BaseViewSet
from plane.app.serializers import (
IssueSerializer,
CycleIssueSerializer,
@@ -38,9 +38,9 @@ from plane.db.models import (
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.issue_filters import issue_filters
+from plane.utils.user_timezone_converter import user_timezone_converter
-
-class CycleIssueViewSet(WebhookMixin, BaseViewSet):
+class CycleIssueViewSet(BaseViewSet):
serializer_class = CycleIssueSerializer
model = CycleIssue
@@ -191,6 +191,11 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
"is_draft",
"archived_at",
)
+ datetime_fields = ["created_at", "updated_at"]
+ issues = user_timezone_converter(
+ issues, datetime_fields, request.user.user_timezone
+ )
+
return Response(issues, status=status.HTTP_200_OK)
def create(self, request, slug, project_id, cycle_id):
@@ -249,6 +254,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
update_cycle_issue_activity = []
# Iterate over each cycle_issue in cycle_issues
for cycle_issue in cycle_issues:
+ old_cycle_id = cycle_issue.cycle_id
# Update the cycle_issue's cycle_id
cycle_issue.cycle_id = cycle_id
# Add the modified cycle_issue to the records_to_update list
@@ -256,7 +262,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
# Record the update activity
update_cycle_issue_activity.append(
{
- "old_cycle_id": str(cycle_issue.cycle_id),
+ "old_cycle_id": str(old_cycle_id),
"new_cycle_id": str(cycle_id),
"issue_id": str(cycle_issue.issue_id),
}
diff --git a/apiserver/plane/app/views/inbox/base.py b/apiserver/plane/app/views/inbox/base.py
index 8e433a127..7919899fa 100644
--- a/apiserver/plane/app/views/inbox/base.py
+++ b/apiserver/plane/app/views/inbox/base.py
@@ -251,6 +251,16 @@ class InboxIssueViewSet(BaseViewSet):
)
if serializer.is_valid():
serializer.save()
+ inbox_id = Inbox.objects.filter(
+ workspace__slug=slug, project_id=project_id
+ ).first()
+ # create an inbox issue
+ inbox_issue = InboxIssue.objects.create(
+ inbox_id=inbox_id.id,
+ project_id=project_id,
+ issue_id=serializer.data["id"],
+ source=request.data.get("source", "in-app"),
+ )
# Create an Issue Activity
issue_activity.delay(
type="issue.activity.created",
@@ -262,16 +272,7 @@ class InboxIssueViewSet(BaseViewSet):
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
- )
- inbox_id = Inbox.objects.filter(
- workspace__slug=slug, project_id=project_id
- ).first()
- # create an inbox issue
- inbox_issue = InboxIssue.objects.create(
- inbox_id=inbox_id.id,
- project_id=project_id,
- issue_id=serializer.data["id"],
- source=request.data.get("source", "in-app"),
+ inbox=str(inbox_issue.id),
)
inbox_issue = (
InboxIssue.objects.select_related("issue")
@@ -339,7 +340,24 @@ class InboxIssueViewSet(BaseViewSet):
# Get issue data
issue_data = request.data.pop("issue", False)
if bool(issue_data):
- issue = Issue.objects.get(
+ issue = Issue.objects.annotate(
+ label_ids=Coalesce(
+ ArrayAgg(
+ "labels__id",
+ distinct=True,
+ filter=~Q(labels__id__isnull=True),
+ ),
+ Value([], output_field=ArrayField(UUIDField())),
+ ),
+ assignee_ids=Coalesce(
+ ArrayAgg(
+ "assignees__id",
+ distinct=True,
+ filter=~Q(assignees__id__isnull=True),
+ ),
+ Value([], output_field=ArrayField(UUIDField())),
+ ),
+ ).get(
pk=inbox_issue.issue_id,
workspace__slug=slug,
project_id=project_id,
@@ -379,6 +397,7 @@ class InboxIssueViewSet(BaseViewSet):
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
+ inbox=str(inbox_issue.id),
)
issue_serializer.save()
else:
@@ -444,15 +463,11 @@ class InboxIssueViewSet(BaseViewSet):
epoch=int(timezone.now().timestamp()),
notification=False,
origin=request.META.get("HTTP_ORIGIN"),
+ inbox=(inbox_issue.id),
)
inbox_issue = (
- InboxIssue.objects.filter(
- inbox_id=inbox_id.id,
- issue_id=serializer.data["id"],
- project_id=project_id,
- )
- .select_related("issue")
+ InboxIssue.objects.select_related("issue")
.prefetch_related(
"issue__labels",
"issue__assignees",
@@ -464,10 +479,7 @@ class InboxIssueViewSet(BaseViewSet):
distinct=True,
filter=~Q(issue__labels__id__isnull=True),
),
- Value(
- [],
- output_field=ArrayField(UUIDField()),
- ),
+ Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
@@ -475,12 +487,14 @@ class InboxIssueViewSet(BaseViewSet):
distinct=True,
filter=~Q(issue__assignees__id__isnull=True),
),
- Value(
- [],
- output_field=ArrayField(UUIDField()),
- ),
+ Value([], output_field=ArrayField(UUIDField())),
),
- ).first()
+ )
+ .get(
+ inbox_id=inbox_id.id,
+ issue_id=issue_id,
+ project_id=project_id,
+ )
)
serializer = InboxIssueDetailSerializer(inbox_issue).data
return Response(serializer, status=status.HTTP_200_OK)
diff --git a/apiserver/plane/app/views/issue/archive.py b/apiserver/plane/app/views/issue/archive.py
index d9274ae4f..cc3a343d2 100644
--- a/apiserver/plane/app/views/issue/archive.py
+++ b/apiserver/plane/app/views/issue/archive.py
@@ -47,7 +47,7 @@ from plane.db.models import (
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.issue_filters import issue_filters
-
+from plane.utils.user_timezone_converter import user_timezone_converter
class IssueArchiveViewSet(BaseViewSet):
permission_classes = [
@@ -239,6 +239,11 @@ class IssueArchiveViewSet(BaseViewSet):
"is_draft",
"archived_at",
)
+ datetime_fields = ["created_at", "updated_at"]
+ issues = user_timezone_converter(
+ issues, datetime_fields, request.user.user_timezone
+ )
+
return Response(issues, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, pk=None):
diff --git a/apiserver/plane/app/views/issue/base.py b/apiserver/plane/app/views/issue/base.py
index 23df58540..fad85b79d 100644
--- a/apiserver/plane/app/views/issue/base.py
+++ b/apiserver/plane/app/views/issue/base.py
@@ -50,9 +50,10 @@ from plane.db.models import (
Project,
)
from plane.utils.issue_filters import issue_filters
+from plane.utils.user_timezone_converter import user_timezone_converter
# Module imports
-from .. import BaseAPIView, BaseViewSet, WebhookMixin
+from .. import BaseAPIView, BaseViewSet
class IssueListEndpoint(BaseAPIView):
@@ -241,10 +242,14 @@ class IssueListEndpoint(BaseAPIView):
"is_draft",
"archived_at",
)
+ datetime_fields = ["created_at", "updated_at"]
+ issues = user_timezone_converter(
+ issues, datetime_fields, request.user.user_timezone
+ )
return Response(issues, status=status.HTTP_200_OK)
-class IssueViewSet(WebhookMixin, BaseViewSet):
+class IssueViewSet(BaseViewSet):
def get_serializer_class(self):
return (
IssueCreateSerializer
@@ -440,6 +445,10 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
"is_draft",
"archived_at",
)
+ datetime_fields = ["created_at", "updated_at"]
+ issues = user_timezone_converter(
+ issues, datetime_fields, request.user.user_timezone
+ )
return Response(issues, status=status.HTTP_200_OK)
def create(self, request, slug, project_id):
@@ -503,6 +512,10 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
)
.first()
)
+ datetime_fields = ["created_at", "updated_at"]
+ issue = user_timezone_converter(
+ issue, datetime_fields, request.user.user_timezone
+ )
return Response(issue, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
diff --git a/apiserver/plane/app/views/issue/comment.py b/apiserver/plane/app/views/issue/comment.py
index 0d61f1325..1698efef8 100644
--- a/apiserver/plane/app/views/issue/comment.py
+++ b/apiserver/plane/app/views/issue/comment.py
@@ -11,7 +11,7 @@ from rest_framework.response import Response
from rest_framework import status
# Module imports
-from .. import BaseViewSet, WebhookMixin
+from .. import BaseViewSet
from plane.app.serializers import (
IssueCommentSerializer,
CommentReactionSerializer,
@@ -25,7 +25,7 @@ from plane.db.models import (
from plane.bgtasks.issue_activites_task import issue_activity
-class IssueCommentViewSet(WebhookMixin, BaseViewSet):
+class IssueCommentViewSet(BaseViewSet):
serializer_class = IssueCommentSerializer
model = IssueComment
webhook_event = "issue_comment"
diff --git a/apiserver/plane/app/views/issue/draft.py b/apiserver/plane/app/views/issue/draft.py
index 077d7dcaf..610c3c468 100644
--- a/apiserver/plane/app/views/issue/draft.py
+++ b/apiserver/plane/app/views/issue/draft.py
@@ -45,6 +45,7 @@ from plane.db.models import (
Project,
)
from plane.utils.issue_filters import issue_filters
+from plane.utils.user_timezone_converter import user_timezone_converter
# Module imports
from .. import BaseViewSet
@@ -229,6 +230,10 @@ class IssueDraftViewSet(BaseViewSet):
"is_draft",
"archived_at",
)
+ datetime_fields = ["created_at", "updated_at"]
+ issues = user_timezone_converter(
+ issues, datetime_fields, request.user.user_timezone
+ )
return Response(issues, status=status.HTTP_200_OK)
def create(self, request, slug, project_id):
diff --git a/apiserver/plane/app/views/issue/sub_issue.py b/apiserver/plane/app/views/issue/sub_issue.py
index da479e0e9..2ee4574eb 100644
--- a/apiserver/plane/app/views/issue/sub_issue.py
+++ b/apiserver/plane/app/views/issue/sub_issue.py
@@ -31,6 +31,7 @@ from plane.db.models import (
IssueAttachment,
)
from plane.bgtasks.issue_activites_task import issue_activity
+from plane.utils.user_timezone_converter import user_timezone_converter
from collections import defaultdict
@@ -132,6 +133,10 @@ class SubIssuesEndpoint(BaseAPIView):
"is_draft",
"archived_at",
)
+ datetime_fields = ["created_at", "updated_at"]
+ sub_issues = user_timezone_converter(
+ sub_issues, datetime_fields, request.user.user_timezone
+ )
return Response(
{
"sub_issues": sub_issues,
diff --git a/apiserver/plane/app/views/module/archive.py b/apiserver/plane/app/views/module/archive.py
index 9c0b6cca3..2cac5f366 100644
--- a/apiserver/plane/app/views/module/archive.py
+++ b/apiserver/plane/app/views/module/archive.py
@@ -25,13 +25,10 @@ from plane.app.permissions import (
from plane.app.serializers import (
ModuleDetailSerializer,
)
-from plane.db.models import (
- Issue,
- Module,
- ModuleFavorite,
- ModuleLink,
-)
+from plane.db.models import Issue, Module, ModuleLink, UserFavorite
from plane.utils.analytics_plot import burndown_plot
+from plane.utils.user_timezone_converter import user_timezone_converter
+
# Module imports
from .. import BaseAPIView
@@ -44,9 +41,10 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
]
def get_queryset(self):
- favorite_subquery = ModuleFavorite.objects.filter(
+ favorite_subquery = UserFavorite.objects.filter(
user=self.request.user,
- module_id=OuterRef("pk"),
+ entity_identifier=OuterRef("pk"),
+ entity_type="module",
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
@@ -199,6 +197,10 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
"updated_at",
"archived_at",
)
+ datetime_fields = ["created_at", "updated_at"]
+ modules = user_timezone_converter(
+ modules, datetime_fields, request.user.user_timezone
+ )
return Response(modules, status=status.HTTP_200_OK)
else:
queryset = (
diff --git a/apiserver/plane/app/views/module/base.py b/apiserver/plane/app/views/module/base.py
index 4cd52b3b1..f98e0fbc2 100644
--- a/apiserver/plane/app/views/module/base.py
+++ b/apiserver/plane/app/views/module/base.py
@@ -1,6 +1,7 @@
# Python imports
import json
+# Django Imports
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import (
@@ -17,21 +18,20 @@ from django.db.models import (
Value,
)
from django.db.models.functions import Coalesce
-
-# Django Imports
+from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone
-from rest_framework import status
# Third party imports
+from rest_framework import status
from rest_framework.response import Response
+# Module imports
from plane.app.permissions import (
ProjectEntityPermission,
ProjectLitePermission,
)
from plane.app.serializers import (
ModuleDetailSerializer,
- ModuleFavoriteSerializer,
ModuleLinkSerializer,
ModuleSerializer,
ModuleUserPropertiesSerializer,
@@ -41,19 +41,19 @@ from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Issue,
Module,
- ModuleFavorite,
+ UserFavorite,
ModuleIssue,
ModuleLink,
ModuleUserProperties,
Project,
)
from plane.utils.analytics_plot import burndown_plot
-
-# Module imports
-from .. import BaseAPIView, BaseViewSet, WebhookMixin
+from plane.utils.user_timezone_converter import user_timezone_converter
+from plane.bgtasks.webhook_task import model_activity
+from .. import BaseAPIView, BaseViewSet
-class ModuleViewSet(WebhookMixin, BaseViewSet):
+class ModuleViewSet(BaseViewSet):
model = Module
permission_classes = [
ProjectEntityPermission,
@@ -68,9 +68,10 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
)
def get_queryset(self):
- favorite_subquery = ModuleFavorite.objects.filter(
+ favorite_subquery = UserFavorite.objects.filter(
user=self.request.user,
- module_id=OuterRef("pk"),
+ entity_type="module",
+ entity_identifier=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
@@ -236,6 +237,20 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
"updated_at",
)
).first()
+ # Send the model activity
+ model_activity.delay(
+ model_name="module",
+ model_id=str(module["id"]),
+ requested_data=request.data,
+ current_instance=None,
+ actor_id=request.user.id,
+ slug=slug,
+ origin=request.META.get("HTTP_ORIGIN"),
+ )
+ datetime_fields = ["created_at", "updated_at"]
+ module = user_timezone_converter(
+ module, datetime_fields, request.user.user_timezone
+ )
return Response(module, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -277,6 +292,10 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
"created_at",
"updated_at",
)
+ datetime_fields = ["created_at", "updated_at"]
+ modules = user_timezone_converter(
+ modules, datetime_fields, request.user.user_timezone
+ )
return Response(modules, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, pk):
@@ -418,6 +437,9 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
{"error": "Archived module cannot be updated"},
status=status.HTTP_400_BAD_REQUEST,
)
+ current_instance = json.dumps(
+ ModuleSerializer(module.first()).data, cls=DjangoJSONEncoder
+ )
serializer = ModuleWriteSerializer(
module.first(), data=request.data, partial=True
)
@@ -454,6 +476,22 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
"created_at",
"updated_at",
).first()
+
+ # Send the model activity
+ model_activity.delay(
+ model_name="module",
+ model_id=str(module["id"]),
+ requested_data=request.data,
+ current_instance=current_instance,
+ actor_id=request.user.id,
+ slug=slug,
+ origin=request.META.get("HTTP_ORIGIN"),
+ )
+
+ datetime_fields = ["created_at", "updated_at"]
+ module = user_timezone_converter(
+ module, datetime_fields, request.user.user_timezone
+ )
return Response(module, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -516,8 +554,7 @@ class ModuleLinkViewSet(BaseViewSet):
class ModuleFavoriteViewSet(BaseViewSet):
- serializer_class = ModuleFavoriteSerializer
- model = ModuleFavorite
+ model = UserFavorite
def get_queryset(self):
return self.filter_queryset(
@@ -529,18 +566,21 @@ class ModuleFavoriteViewSet(BaseViewSet):
)
def create(self, request, slug, project_id):
- serializer = ModuleFavoriteSerializer(data=request.data)
- if serializer.is_valid():
- serializer.save(user=request.user, project_id=project_id)
- return Response(serializer.data, status=status.HTTP_201_CREATED)
- return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+ _ = UserFavorite.objects.create(
+ project_id=project_id,
+ user=request.user,
+ entity_type="module",
+ entity_identifier=request.data.get("module"),
+ )
+ return Response(status=status.HTTP_204_NO_CONTENT)
def destroy(self, request, slug, project_id, module_id):
- module_favorite = ModuleFavorite.objects.get(
- project=project_id,
+ module_favorite = UserFavorite.objects.get(
+ project_id=project_id,
user=request.user,
workspace__slug=slug,
- module_id=module_id,
+ entity_type="module",
+ entity_identifier=module_id,
)
module_favorite.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
diff --git a/apiserver/plane/app/views/module/issue.py b/apiserver/plane/app/views/module/issue.py
index d26433340..3e79e7ec7 100644
--- a/apiserver/plane/app/views/module/issue.py
+++ b/apiserver/plane/app/views/module/issue.py
@@ -16,7 +16,7 @@ from rest_framework.response import Response
from rest_framework import status
# Module imports
-from .. import BaseViewSet, WebhookMixin
+from .. import BaseViewSet
from plane.app.serializers import (
ModuleIssueSerializer,
IssueSerializer,
@@ -31,9 +31,9 @@ from plane.db.models import (
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.issue_filters import issue_filters
+from plane.utils.user_timezone_converter import user_timezone_converter
-
-class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
+class ModuleIssueViewSet(BaseViewSet):
serializer_class = ModuleIssueSerializer
model = ModuleIssue
webhook_event = "module_issue"
@@ -150,6 +150,11 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
"is_draft",
"archived_at",
)
+ datetime_fields = ["created_at", "updated_at"]
+ issues = user_timezone_converter(
+ issues, datetime_fields, request.user.user_timezone
+ )
+
return Response(issues, status=status.HTTP_200_OK)
# create multiple issues inside a module
diff --git a/apiserver/plane/app/views/page/base.py b/apiserver/plane/app/views/page/base.py
index 29dc2dbf5..16ea78033 100644
--- a/apiserver/plane/app/views/page/base.py
+++ b/apiserver/plane/app/views/page/base.py
@@ -15,7 +15,6 @@ from rest_framework.response import Response
from plane.app.permissions import ProjectEntityPermission
from plane.app.serializers import (
- PageFavoriteSerializer,
PageLogSerializer,
PageSerializer,
SubPageSerializer,
@@ -23,8 +22,8 @@ from plane.app.serializers import (
)
from plane.db.models import (
Page,
- PageFavorite,
PageLog,
+ UserFavorite,
ProjectMember,
)
@@ -61,9 +60,10 @@ class PageViewSet(BaseViewSet):
]
def get_queryset(self):
- subquery = PageFavorite.objects.filter(
+ subquery = UserFavorite.objects.filter(
user=self.request.user,
- page_id=OuterRef("pk"),
+ entity_type="page",
+ entity_identifier=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
@@ -303,23 +303,24 @@ class PageFavoriteViewSet(BaseViewSet):
ProjectEntityPermission,
]
- serializer_class = PageFavoriteSerializer
- model = PageFavorite
+ model = UserFavorite
def create(self, request, slug, project_id, pk):
- _ = PageFavorite.objects.create(
+ _ = UserFavorite.objects.create(
project_id=project_id,
- page_id=pk,
+ entity_identifier=pk,
+ entity_type="page",
user=request.user,
)
return Response(status=status.HTTP_204_NO_CONTENT)
def destroy(self, request, slug, project_id, pk):
- page_favorite = PageFavorite.objects.get(
+ page_favorite = UserFavorite.objects.get(
project=project_id,
user=request.user,
workspace__slug=slug,
- page_id=pk,
+ entity_identifier=pk,
+ entity_type="page",
)
page_favorite.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py
index 50435e3a8..39db11871 100644
--- a/apiserver/plane/app/views/project/base.py
+++ b/apiserver/plane/app/views/project/base.py
@@ -1,5 +1,6 @@
# Python imports
import boto3
+import json
# Django imports
from django.db import IntegrityError
@@ -14,6 +15,7 @@ from django.db.models import (
)
from django.conf import settings
from django.utils import timezone
+from django.core.serializers.json import DjangoJSONEncoder
# Third Party imports
from rest_framework.response import Response
@@ -22,11 +24,10 @@ from rest_framework import serializers
from rest_framework.permissions import AllowAny
# Module imports
-from plane.app.views.base import BaseViewSet, BaseAPIView, WebhookMixin
+from plane.app.views.base import BaseViewSet, BaseAPIView
from plane.app.serializers import (
ProjectSerializer,
ProjectListSerializer,
- ProjectFavoriteSerializer,
ProjectDeployBoardSerializer,
)
@@ -40,7 +41,7 @@ from plane.db.models import (
ProjectMember,
Workspace,
State,
- ProjectFavorite,
+ UserFavorite,
ProjectIdentifier,
Module,
Cycle,
@@ -50,9 +51,10 @@ from plane.db.models import (
Issue,
)
from plane.utils.cache import cache_response
+from plane.bgtasks.webhook_task import model_activity
-class ProjectViewSet(WebhookMixin, BaseViewSet):
+class ProjectViewSet(BaseViewSet):
serializer_class = ProjectListSerializer
model = Project
webhook_event = "project"
@@ -87,10 +89,11 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
)
.annotate(
is_favorite=Exists(
- ProjectFavorite.objects.filter(
+ UserFavorite.objects.filter(
user=self.request.user,
+ entity_identifier=OuterRef("pk"),
+ entity_type="project",
project_id=OuterRef("pk"),
- workspace__slug=self.kwargs.get("slug"),
)
)
)
@@ -185,7 +188,6 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
.annotate(
total_issues=Issue.issue_objects.filter(
project_id=self.kwargs.get("pk"),
- parent__isnull=True,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
@@ -204,7 +206,6 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
archived_issues=Issue.objects.filter(
project_id=self.kwargs.get("pk"),
archived_at__isnull=False,
- parent__isnull=True,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
@@ -224,7 +225,6 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
draft_issues=Issue.objects.filter(
project_id=self.kwargs.get("pk"),
is_draft=True,
- parent__isnull=True,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
@@ -337,6 +337,17 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
.filter(pk=serializer.data["id"])
.first()
)
+
+ model_activity.delay(
+ model_name="project",
+ model_id=str(project.id),
+ requested_data=request.data,
+ current_instance=None,
+ actor_id=request.user.id,
+ slug=slug,
+ origin=request.META.get("HTTP_ORIGIN"),
+ )
+
serializer = ProjectListSerializer(project)
return Response(
serializer.data, status=status.HTTP_201_CREATED
@@ -367,7 +378,9 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(pk=pk)
-
+ current_instance = json.dumps(
+ ProjectSerializer(project).data, cls=DjangoJSONEncoder
+ )
if project.archived_at:
return Response(
{"error": "Archived projects cannot be updated"},
@@ -405,6 +418,16 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
.filter(pk=serializer.data["id"])
.first()
)
+
+ model_activity.delay(
+ model_name="project",
+ model_id=str(project.id),
+ requested_data=request.data,
+ current_instance=current_instance,
+ actor_id=request.user.id,
+ slug=slug,
+ origin=request.META.get("HTTP_ORIGIN"),
+ )
serializer = ProjectListSerializer(project)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(
@@ -537,8 +560,7 @@ class ProjectUserViewsEndpoint(BaseAPIView):
class ProjectFavoritesViewSet(BaseViewSet):
- serializer_class = ProjectFavoriteSerializer
- model = ProjectFavorite
+ model = UserFavorite
def get_queryset(self):
return self.filter_queryset(
@@ -556,15 +578,21 @@ class ProjectFavoritesViewSet(BaseViewSet):
serializer.save(user=self.request.user)
def create(self, request, slug):
- serializer = ProjectFavoriteSerializer(data=request.data)
- if serializer.is_valid():
- serializer.save(user=request.user)
- return Response(serializer.data, status=status.HTTP_201_CREATED)
- return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+ _ = UserFavorite.objects.create(
+ user=request.user,
+ entity_type="project",
+ entity_identifier=request.data.get("project"),
+ project_id=request.data.get("project"),
+ )
+ return Response(status=status.HTTP_204_NO_CONTENT)
def destroy(self, request, slug, project_id):
- project_favorite = ProjectFavorite.objects.get(
- project=project_id, user=request.user, workspace__slug=slug
+ project_favorite = UserFavorite.objects.get(
+ entity_identifier=project_id,
+ entity_type="project",
+ project=project_id,
+ user=request.user,
+ workspace__slug=slug,
)
project_favorite.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -579,11 +607,19 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView):
@cache_response(60 * 60 * 24, user=False)
def get(self, request):
files = []
- s3 = boto3.client(
- "s3",
- aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
- aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
- )
+ if settings.USE_MINIO:
+ s3 = boto3.client(
+ "s3",
+ endpoint_url=settings.AWS_S3_ENDPOINT_URL,
+ aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
+ aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
+ )
+ else:
+ s3 = boto3.client(
+ "s3",
+ aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
+ aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
+ )
params = {
"Bucket": settings.AWS_STORAGE_BUCKET_NAME,
"Prefix": "static/project-cover/",
diff --git a/apiserver/plane/app/views/search.py b/apiserver/plane/app/views/search.py
index 4a4ffd826..93bab2de3 100644
--- a/apiserver/plane/app/views/search.py
+++ b/apiserver/plane/app/views/search.py
@@ -289,6 +289,7 @@ class IssueSearchEndpoint(BaseAPIView):
issues.values(
"name",
"id",
+ "start_date",
"sequence_id",
"project__name",
"project__identifier",
diff --git a/apiserver/plane/app/views/user/base.py b/apiserver/plane/app/views/user/base.py
index 60823a5a7..5a75f8105 100644
--- a/apiserver/plane/app/views/user/base.py
+++ b/apiserver/plane/app/views/user/base.py
@@ -1,9 +1,12 @@
# Django imports
from django.db.models import Case, Count, IntegerField, Q, When
+from django.contrib.auth import logout
+from django.utils import timezone
# Third party imports
from rest_framework import status
from rest_framework.response import Response
+from rest_framework.permissions import AllowAny
# Module imports
from plane.app.serializers import (
@@ -22,10 +25,12 @@ from plane.db.models import (
ProjectMember,
User,
WorkspaceMember,
+ WorkspaceMemberInvite,
)
from plane.license.models import Instance, InstanceAdmin
from plane.utils.cache import cache_response, invalidate_cache
from plane.utils.paginator import BasePaginator
+from plane.authentication.utils.host import user_ip
class UserEndpoint(BaseViewSet):
@@ -150,6 +155,11 @@ class UserEndpoint(BaseViewSet):
workspaces_to_deactivate, ["is_active"], batch_size=100
)
+ # Delete all workspace invites
+ WorkspaceMemberInvite.objects.filter(
+ email=user.email,
+ ).delete()
+
# Deactivate the user
user.is_active = False
@@ -166,10 +176,36 @@ class UserEndpoint(BaseViewSet):
"workspace_invite": False,
}
profile.save()
+
+ # User log out
+ user.last_logout_ip = user_ip(request=request)
+ user.last_logout_time = timezone.now()
user.save()
+
+ # Logout the user
+ logout(request)
return Response(status=status.HTTP_204_NO_CONTENT)
+class UserSessionEndpoint(BaseAPIView):
+
+ permission_classes = [
+ AllowAny,
+ ]
+
+ def get(self, request):
+ if request.user.is_authenticated:
+ user = User.objects.get(pk=request.user.id)
+ serializer = UserMeSerializer(user)
+ data = {"is_authenticated": True}
+ data["user"] = serializer.data
+ return Response(data, status=status.HTTP_200_OK)
+ else:
+ return Response(
+ {"is_authenticated": False}, status=status.HTTP_200_OK
+ )
+
+
class UpdateUserOnBoardedEndpoint(BaseAPIView):
@invalidate_cache(path="/api/users/me/")
@@ -239,6 +275,7 @@ class ProfileEndpoint(BaseAPIView):
serializer = ProfileSerializer(profile)
return Response(serializer.data, status=status.HTTP_200_OK)
+ @invalidate_cache("/api/users/me/settings/")
def patch(self, request):
profile = Profile.objects.get(user=request.user)
serializer = ProfileSerializer(
diff --git a/apiserver/plane/app/views/view/base.py b/apiserver/plane/app/views/view/base.py
index 35772ccf3..72c27d20a 100644
--- a/apiserver/plane/app/views/view/base.py
+++ b/apiserver/plane/app/views/view/base.py
@@ -27,7 +27,6 @@ from .. import BaseViewSet
from plane.app.serializers import (
IssueViewSerializer,
IssueSerializer,
- IssueViewFavoriteSerializer,
)
from plane.app.permissions import (
WorkspaceEntityPermission,
@@ -37,12 +36,12 @@ from plane.db.models import (
Workspace,
IssueView,
Issue,
- IssueViewFavorite,
+ UserFavorite,
IssueLink,
IssueAttachment,
)
from plane.utils.issue_filters import issue_filters
-
+from plane.utils.user_timezone_converter import user_timezone_converter
class GlobalViewViewSet(BaseViewSet):
serializer_class = IssueViewSerializer
@@ -255,6 +254,10 @@ class GlobalViewIssuesViewSet(BaseViewSet):
"is_draft",
"archived_at",
)
+ datetime_fields = ["created_at", "updated_at"]
+ issues = user_timezone_converter(
+ issues, datetime_fields, request.user.user_timezone
+ )
return Response(issues, status=status.HTTP_200_OK)
@@ -269,9 +272,10 @@ class IssueViewViewSet(BaseViewSet):
serializer.save(project_id=self.kwargs.get("project_id"))
def get_queryset(self):
- subquery = IssueViewFavorite.objects.filter(
+ subquery = UserFavorite.objects.filter(
user=self.request.user,
- view_id=OuterRef("pk"),
+ entity_identifier=OuterRef("pk"),
+ entity_type="view",
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
@@ -306,8 +310,7 @@ class IssueViewViewSet(BaseViewSet):
class IssueViewFavoriteViewSet(BaseViewSet):
- serializer_class = IssueViewFavoriteSerializer
- model = IssueViewFavorite
+ model = UserFavorite
def get_queryset(self):
return self.filter_queryset(
@@ -319,18 +322,21 @@ class IssueViewFavoriteViewSet(BaseViewSet):
)
def create(self, request, slug, project_id):
- serializer = IssueViewFavoriteSerializer(data=request.data)
- if serializer.is_valid():
- serializer.save(user=request.user, project_id=project_id)
- return Response(serializer.data, status=status.HTTP_201_CREATED)
- return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+ _ = UserFavorite.objects.create(
+ user=request.user,
+ entity_identifier=request.data.get("view"),
+ entity_type="view",
+ project_id=project_id,
+ )
+ return Response(status=status.HTTP_204_NO_CONTENT)
def destroy(self, request, slug, project_id, view_id):
- view_favorite = IssueViewFavorite.objects.get(
+ view_favorite = UserFavorite.objects.get(
project=project_id,
user=request.user,
workspace__slug=slug,
- view_id=view_id,
+ entity_type="view",
+ entity_identifier=view_id,
)
view_favorite.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
diff --git a/apiserver/plane/app/views/workspace/base.py b/apiserver/plane/app/views/workspace/base.py
index 24a3d7302..830ae1dc2 100644
--- a/apiserver/plane/app/views/workspace/base.py
+++ b/apiserver/plane/app/views/workspace/base.py
@@ -96,6 +96,7 @@ class WorkSpaceViewSet(BaseViewSet):
@invalidate_cache(path="/api/workspaces/", user=False)
@invalidate_cache(path="/api/users/me/workspaces/")
+ @invalidate_cache(path="/api/instances/", user=False)
def create(self, request):
try:
serializer = WorkSpaceSerializer(data=request.data)
@@ -151,8 +152,12 @@ class WorkSpaceViewSet(BaseViewSet):
return super().partial_update(request, *args, **kwargs)
@invalidate_cache(path="/api/workspaces/", user=False)
- @invalidate_cache(path="/api/users/me/workspaces/", multiple=True, user=False)
- @invalidate_cache(path="/api/users/me/settings/", multiple=True, user=False)
+ @invalidate_cache(
+ path="/api/users/me/workspaces/", multiple=True, user=False
+ )
+ @invalidate_cache(
+ path="/api/users/me/settings/", multiple=True, user=False
+ )
def destroy(self, request, *args, **kwargs):
return super().destroy(request, *args, **kwargs)
diff --git a/apiserver/plane/app/views/workspace/cycle.py b/apiserver/plane/app/views/workspace/cycle.py
index e85fa1cef..fa2954d67 100644
--- a/apiserver/plane/app/views/workspace/cycle.py
+++ b/apiserver/plane/app/views/workspace/cycle.py
@@ -27,7 +27,7 @@ class WorkspaceCyclesEndpoint(BaseAPIView):
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
- .filter(archived_at__isnull=False)
+ .filter(archived_at__isnull=True)
.annotate(
total_issues=Count(
"issue_cycle",
diff --git a/apiserver/plane/app/views/workspace/module.py b/apiserver/plane/app/views/workspace/module.py
index 085787694..7671692ec 100644
--- a/apiserver/plane/app/views/workspace/module.py
+++ b/apiserver/plane/app/views/workspace/module.py
@@ -30,7 +30,7 @@ class WorkspaceModulesEndpoint(BaseAPIView):
.select_related("workspace")
.select_related("lead")
.prefetch_related("members")
- .filter(archived_at__isnull=False)
+ .filter(archived_at__isnull=True)
.prefetch_related(
Prefetch(
"link_module",
diff --git a/apiserver/plane/authentication/adapter/base.py b/apiserver/plane/authentication/adapter/base.py
index 9c5c83a4a..7b899e63c 100644
--- a/apiserver/plane/authentication/adapter/base.py
+++ b/apiserver/plane/authentication/adapter/base.py
@@ -3,7 +3,6 @@ import os
import uuid
# Django imports
-from django.core.exceptions import ImproperlyConfigured
from django.utils import timezone
# Third party imports
@@ -16,24 +15,16 @@ from plane.db.models import (
WorkspaceMemberInvite,
)
from plane.license.utils.instance_value import get_configuration_value
-
-
-class AuthenticationException(Exception):
-
- error_code = None
- error_message = None
-
- def __init__(self, error_code, error_message):
- self.error_code = error_code
- self.error_message = error_message
+from .error import AuthenticationException, AUTHENTICATION_ERROR_CODES
class Adapter:
"""Common interface for all auth providers"""
- def __init__(self, request, provider):
+ def __init__(self, request, provider, callback=None):
self.request = request
self.provider = provider
+ self.callback = callback
self.token_data = None
self.user_data = None
@@ -58,7 +49,8 @@ class Adapter:
def complete_login_or_signup(self):
email = self.user_data.get("email")
user = User.objects.filter(email=email).first()
-
+ # Check if sign up case or login
+ is_signup = bool(user)
if not user:
# New user
(ENABLE_SIGNUP,) = get_configuration_value(
@@ -75,8 +67,10 @@ class Adapter:
email=email,
).exists()
):
- raise ImproperlyConfigured(
- "Account creation is disabled for this instance please contact your admin"
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["SIGNUP_DISABLED"],
+ error_message="SIGNUP_DISABLED",
+ payload={"email": email},
)
user = User(email=email, username=uuid.uuid4().hex)
@@ -89,8 +83,11 @@ class Adapter:
results = zxcvbn(self.code)
if results["score"] < 3:
raise AuthenticationException(
- error_message="The password is not a valid password",
- error_code="INVALID_PASSWORD",
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INVALID_PASSWORD"
+ ],
+ error_message="INVALID_PASSWORD",
+ payload={"email": email},
)
user.set_password(self.code)
@@ -105,6 +102,12 @@ class Adapter:
user.save()
Profile.objects.create(user=user)
+ if not user.is_active:
+ raise AuthenticationException(
+ AUTHENTICATION_ERROR_CODES["USER_ACCOUNT_DEACTIVATED"],
+ error_message="USER_ACCOUNT_DEACTIVATED",
+ )
+
# Update user details
user.last_login_medium = self.provider
user.last_active = timezone.now()
@@ -114,6 +117,13 @@ class Adapter:
user.token_updated_at = timezone.now()
user.save()
+ if self.callback:
+ self.callback(
+ user,
+ is_signup,
+ self.request,
+ )
+
if self.token_data:
self.create_update_account(user=user)
diff --git a/apiserver/plane/authentication/adapter/credential.py b/apiserver/plane/authentication/adapter/credential.py
index b1fd75d02..0327289ca 100644
--- a/apiserver/plane/authentication/adapter/credential.py
+++ b/apiserver/plane/authentication/adapter/credential.py
@@ -4,8 +4,8 @@ from plane.authentication.adapter.base import Adapter
class CredentialAdapter(Adapter):
"""Common interface for all credential providers"""
- def __init__(self, request, provider):
- super().__init__(request, provider)
+ def __init__(self, request, provider, callback=None):
+ super().__init__(request=request, provider=provider, callback=callback)
self.request = request
self.provider = provider
diff --git a/apiserver/plane/authentication/adapter/error.py b/apiserver/plane/authentication/adapter/error.py
new file mode 100644
index 000000000..47dbc8e8a
--- /dev/null
+++ b/apiserver/plane/authentication/adapter/error.py
@@ -0,0 +1,80 @@
+AUTHENTICATION_ERROR_CODES = {
+ # Global
+ "INSTANCE_NOT_CONFIGURED": 5000,
+ "INVALID_EMAIL": 5005,
+ "EMAIL_REQUIRED": 5010,
+ "SIGNUP_DISABLED": 5015,
+ "MAGIC_LINK_LOGIN_DISABLED": 5016,
+ "PASSWORD_LOGIN_DISABLED": 5018,
+ "USER_ACCOUNT_DEACTIVATED": 5019,
+ # Password strength
+ "INVALID_PASSWORD": 5020,
+ "SMTP_NOT_CONFIGURED": 5025,
+ # Sign Up
+ "USER_ALREADY_EXIST": 5030,
+ "AUTHENTICATION_FAILED_SIGN_UP": 5035,
+ "REQUIRED_EMAIL_PASSWORD_SIGN_UP": 5040,
+ "INVALID_EMAIL_SIGN_UP": 5045,
+ "INVALID_EMAIL_MAGIC_SIGN_UP": 5050,
+ "MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED": 5055,
+ # Sign In
+ "USER_DOES_NOT_EXIST": 5060,
+ "AUTHENTICATION_FAILED_SIGN_IN": 5065,
+ "REQUIRED_EMAIL_PASSWORD_SIGN_IN": 5070,
+ "INVALID_EMAIL_SIGN_IN": 5075,
+ "INVALID_EMAIL_MAGIC_SIGN_IN": 5080,
+ "MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED": 5085,
+ # Both Sign in and Sign up for magic
+ "INVALID_MAGIC_CODE_SIGN_IN": 5090,
+ "INVALID_MAGIC_CODE_SIGN_UP": 5092,
+ "EXPIRED_MAGIC_CODE_SIGN_IN": 5095,
+ "EXPIRED_MAGIC_CODE_SIGN_UP": 5097,
+ "EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN": 5100,
+ "EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP": 5102,
+ # Oauth
+ "GOOGLE_NOT_CONFIGURED": 5105,
+ "GITHUB_NOT_CONFIGURED": 5110,
+ "GOOGLE_OAUTH_PROVIDER_ERROR": 5115,
+ "GITHUB_OAUTH_PROVIDER_ERROR": 5120,
+ # Reset Password
+ "INVALID_PASSWORD_TOKEN": 5125,
+ "EXPIRED_PASSWORD_TOKEN": 5130,
+ # Change password
+ "INCORRECT_OLD_PASSWORD": 5135,
+ "MISSING_PASSWORD": 5138,
+ "INVALID_NEW_PASSWORD": 5140,
+ # set passowrd
+ "PASSWORD_ALREADY_SET": 5145,
+ # Admin
+ "ADMIN_ALREADY_EXIST": 5150,
+ "REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME": 5155,
+ "INVALID_ADMIN_EMAIL": 5160,
+ "INVALID_ADMIN_PASSWORD": 5165,
+ "REQUIRED_ADMIN_EMAIL_PASSWORD": 5170,
+ "ADMIN_AUTHENTICATION_FAILED": 5175,
+ "ADMIN_USER_ALREADY_EXIST": 5180,
+ "ADMIN_USER_DOES_NOT_EXIST": 5185,
+ "ADMIN_USER_DEACTIVATED": 5190,
+}
+
+
+class AuthenticationException(Exception):
+
+ error_code = None
+ error_message = None
+ payload = {}
+
+ def __init__(self, error_code, error_message, payload={}):
+ self.error_code = error_code
+ self.error_message = error_message
+ self.payload = payload
+
+ def get_error_dict(self):
+ error = {
+ "error_code": self.error_code,
+ "error_message": self.error_message,
+ }
+ for key in self.payload:
+ error[key] = self.payload[key]
+
+ return error
diff --git a/apiserver/plane/authentication/adapter/exception.py b/apiserver/plane/authentication/adapter/exception.py
new file mode 100644
index 000000000..12845ea02
--- /dev/null
+++ b/apiserver/plane/authentication/adapter/exception.py
@@ -0,0 +1,12 @@
+from rest_framework.views import exception_handler
+from rest_framework.exceptions import NotAuthenticated
+
+
+def auth_exception_handler(exc, context):
+ # Call the default exception handler first, to get the standard error response.
+ response = exception_handler(exc, context)
+ # Check if an AuthenticationFailed exception is raised.
+ if isinstance(exc, NotAuthenticated):
+ response.status_code = 401
+
+ return response
diff --git a/apiserver/plane/authentication/adapter/oauth.py b/apiserver/plane/authentication/adapter/oauth.py
index 91cab7c5f..b841db99d 100644
--- a/apiserver/plane/authentication/adapter/oauth.py
+++ b/apiserver/plane/authentication/adapter/oauth.py
@@ -23,8 +23,9 @@ class OauthAdapter(Adapter):
userinfo_url,
client_secret=None,
code=None,
+ callback=None,
):
- super().__init__(request, provider)
+ super().__init__(request=request, provider=provider, callback=callback)
self.client_id = client_id
self.scope = scope
self.redirect_uri = redirect_uri
diff --git a/apiserver/plane/authentication/middleware/session.py b/apiserver/plane/authentication/middleware/session.py
index 697881e35..2bb62b881 100644
--- a/apiserver/plane/authentication/middleware/session.py
+++ b/apiserver/plane/authentication/middleware/session.py
@@ -38,11 +38,13 @@ class SessionMiddleware(MiddlewareMixin):
return response
# First check if we need to delete this cookie.
# The session should be deleted only if the session is entirely empty.
+ is_admin_path = "instances" in request.path
cookie_name = (
settings.ADMIN_SESSION_COOKIE_NAME
- if "instances" in request.path
+ if is_admin_path
else settings.SESSION_COOKIE_NAME
)
+
if cookie_name in request.COOKIES and empty:
response.delete_cookie(
cookie_name,
@@ -59,11 +61,16 @@ class SessionMiddleware(MiddlewareMixin):
max_age = None
expires = None
else:
- max_age = request.session.get_expiry_age()
+ # Use different max_age based on whether it's an admin cookie
+ if is_admin_path:
+ max_age = settings.ADMIN_SESSION_COOKIE_AGE
+ else:
+ max_age = request.session.get_expiry_age()
+
expires_time = time.time() + max_age
expires = http_date(expires_time)
+
# Save the session data and refresh the client cookie.
- # Skip session save for 5xx responses.
if response.status_code < 500:
try:
request.session.save()
diff --git a/apiserver/plane/authentication/provider/credentials/email.py b/apiserver/plane/authentication/provider/credentials/email.py
index 77c86da30..7e4e619d8 100644
--- a/apiserver/plane/authentication/provider/credentials/email.py
+++ b/apiserver/plane/authentication/provider/credentials/email.py
@@ -1,7 +1,14 @@
+# Python imports
+import os
+
# Module imports
-from plane.authentication.adapter.base import AuthenticationException
from plane.authentication.adapter.credential import CredentialAdapter
from plane.db.models import User
+from plane.authentication.adapter.error import (
+ AUTHENTICATION_ERROR_CODES,
+ AuthenticationException,
+)
+from plane.license.utils.instance_value import get_configuration_value
class EmailProvider(CredentialAdapter):
@@ -14,19 +21,39 @@ class EmailProvider(CredentialAdapter):
key=None,
code=None,
is_signup=False,
+ callback=None,
):
- super().__init__(request, self.provider)
+ super().__init__(
+ request=request, provider=self.provider, callback=callback
+ )
self.key = key
self.code = code
self.is_signup = is_signup
+ (ENABLE_EMAIL_PASSWORD,) = get_configuration_value(
+ [
+ {
+ "key": "ENABLE_EMAIL_PASSWORD",
+ "default": os.environ.get("ENABLE_EMAIL_PASSWORD"),
+ },
+ ]
+ )
+
+ if ENABLE_EMAIL_PASSWORD == "0":
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["ENABLE_EMAIL_PASSWORD"],
+ error_message="ENABLE_EMAIL_PASSWORD",
+ )
+
def set_user_data(self):
if self.is_signup:
# Check if the user already exists
if User.objects.filter(email=self.key).exists():
raise AuthenticationException(
- error_message="User with this email already exists",
- error_code="USER_ALREADY_EXIST",
+ error_message="USER_ALREADY_EXIST",
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "USER_ALREADY_EXIST"
+ ],
)
super().set_user_data(
@@ -46,18 +73,35 @@ class EmailProvider(CredentialAdapter):
user = User.objects.filter(
email=self.key,
).first()
- # Existing user
+
+ # User does not exists
if not user:
raise AuthenticationException(
- error_message="Sorry, we could not find a user with the provided credentials. Please try again.",
- error_code="AUTHENTICATION_FAILED",
+ error_message="USER_DOES_NOT_EXIST",
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "USER_DOES_NOT_EXIST"
+ ],
+ payload={
+ "email": self.key,
+ },
)
# Check user password
if not user.check_password(self.code):
raise AuthenticationException(
- error_message="Sorry, we could not find a user with the provided credentials. Please try again.",
- error_code="AUTHENTICATION_FAILED",
+ error_message=(
+ "AUTHENTICATION_FAILED_SIGN_UP"
+ if self.is_signup
+ else "AUTHENTICATION_FAILED_SIGN_IN"
+ ),
+ error_code=AUTHENTICATION_ERROR_CODES[
+ (
+ "AUTHENTICATION_FAILED_SIGN_UP"
+ if self.is_signup
+ else "AUTHENTICATION_FAILED_SIGN_IN"
+ )
+ ],
+ payload={"email": self.key},
)
super().set_user_data(
diff --git a/apiserver/plane/authentication/provider/credentials/magic_code.py b/apiserver/plane/authentication/provider/credentials/magic_code.py
index d49f19429..21309ea9c 100644
--- a/apiserver/plane/authentication/provider/credentials/magic_code.py
+++ b/apiserver/plane/authentication/provider/credentials/magic_code.py
@@ -4,14 +4,16 @@ import os
import random
import string
-# Django imports
-from django.core.exceptions import ImproperlyConfigured
# Module imports
-from plane.authentication.adapter.base import AuthenticationException
from plane.authentication.adapter.credential import CredentialAdapter
from plane.license.utils.instance_value import get_configuration_value
from plane.settings.redis import redis_instance
+from plane.authentication.adapter.error import (
+ AUTHENTICATION_ERROR_CODES,
+ AuthenticationException,
+)
+from plane.db.models import User
class MagicCodeProvider(CredentialAdapter):
@@ -23,33 +25,44 @@ class MagicCodeProvider(CredentialAdapter):
request,
key,
code=None,
+ callback=None,
):
- (EMAIL_HOST, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD) = (
- get_configuration_value(
- [
- {
- "key": "EMAIL_HOST",
- "default": os.environ.get("EMAIL_HOST"),
- },
- {
- "key": "EMAIL_HOST_USER",
- "default": os.environ.get("EMAIL_HOST_USER"),
- },
- {
- "key": "EMAIL_HOST_PASSWORD",
- "default": os.environ.get("EMAIL_HOST_PASSWORD"),
- },
- ]
- )
+ (
+ EMAIL_HOST,
+ ENABLE_MAGIC_LINK_LOGIN,
+ ) = get_configuration_value(
+ [
+ {
+ "key": "EMAIL_HOST",
+ "default": os.environ.get("EMAIL_HOST"),
+ },
+ {
+ "key": "ENABLE_MAGIC_LINK_LOGIN",
+ "default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "1"),
+ },
+ ]
)
- if not (EMAIL_HOST and EMAIL_HOST_USER and EMAIL_HOST_PASSWORD):
- raise ImproperlyConfigured(
- "SMTP is not configured. Please contact the support team."
+ if not (EMAIL_HOST):
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["SMTP_NOT_CONFIGURED"],
+ error_message="SMTP_NOT_CONFIGURED",
+ payload={"email": str(self.key)},
)
- super().__init__(request, self.provider)
+ if ENABLE_MAGIC_LINK_LOGIN == "0":
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "MAGIC_LINK_LOGIN_DISABLED"
+ ],
+ error_message="MAGIC_LINK_LOGIN_DISABLED",
+ payload={"email": str(self.key)},
+ )
+
+ super().__init__(
+ request=request, provider=self.provider, callback=callback
+ )
self.key = key
self.code = code
@@ -74,7 +87,23 @@ class MagicCodeProvider(CredentialAdapter):
current_attempt = data["current_attempt"] + 1
if data["current_attempt"] > 2:
- return key, ""
+ email = str(self.key).replace("magic_", "", 1)
+ if User.objects.filter(email=email).exists():
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN"
+ ],
+ error_message="EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN",
+ payload={"email": str(email)},
+ )
+ else:
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP"
+ ],
+ error_message="EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP",
+ payload={"email": self.key},
+ )
value = {
"current_attempt": current_attempt,
@@ -110,14 +139,42 @@ class MagicCodeProvider(CredentialAdapter):
},
}
)
+ # Delete the token from redis if the code match is successful
+ ri.delete(self.key)
return
else:
- raise AuthenticationException(
- error_message="The token is not valid.",
- error_code="INVALID_TOKEN",
- )
+ email = str(self.key).replace("magic_", "", 1)
+ if User.objects.filter(email=email).exists():
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INVALID_MAGIC_CODE_SIGN_IN"
+ ],
+ error_message="INVALID_MAGIC_CODE_SIGN_IN",
+ payload={"email": str(email)},
+ )
+ else:
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INVALID_MAGIC_CODE_SIGN_UP"
+ ],
+ error_message="INVALID_MAGIC_CODE_SIGN_UP",
+ payload={"email": str(email)},
+ )
else:
- raise AuthenticationException(
- error_message="The token has expired. Please regenerate the token and try again.",
- error_code="EXPIRED_TOKEN",
- )
+ email = str(self.key).replace("magic_", "", 1)
+ if User.objects.filter(email=email).exists():
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "EXPIRED_MAGIC_CODE_SIGN_IN"
+ ],
+ error_message="EXPIRED_MAGIC_CODE_SIGN_IN",
+ payload={"email": str(email)},
+ )
+ else:
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "EXPIRED_MAGIC_CODE_SIGN_UP"
+ ],
+ error_message="EXPIRED_MAGIC_CODE_SIGN_UP",
+ payload={"email": str(email)},
+ )
diff --git a/apiserver/plane/authentication/provider/oauth/github.py b/apiserver/plane/authentication/provider/oauth/github.py
index ad8d913a1..edccea449 100644
--- a/apiserver/plane/authentication/provider/oauth/github.py
+++ b/apiserver/plane/authentication/provider/oauth/github.py
@@ -6,12 +6,13 @@ from urllib.parse import urlencode
import pytz
import requests
-# Django imports
-from django.core.exceptions import ImproperlyConfigured
-
# Module imports
from plane.authentication.adapter.oauth import OauthAdapter
from plane.license.utils.instance_value import get_configuration_value
+from plane.authentication.adapter.error import (
+ AuthenticationException,
+ AUTHENTICATION_ERROR_CODES,
+)
class GitHubOAuthProvider(OauthAdapter):
@@ -21,7 +22,7 @@ class GitHubOAuthProvider(OauthAdapter):
provider = "github"
scope = "read:user user:email"
- def __init__(self, request, code=None, state=None):
+ def __init__(self, request, code=None, state=None, callback=None):
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET = get_configuration_value(
[
@@ -37,16 +38,15 @@ class GitHubOAuthProvider(OauthAdapter):
)
if not (GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET):
- raise ImproperlyConfigured(
- "Google is not configured. Please contact the support team."
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["GITHUB_NOT_CONFIGURED"],
+ error_message="GITHUB_NOT_CONFIGURED",
)
client_id = GITHUB_CLIENT_ID
client_secret = GITHUB_CLIENT_SECRET
- redirect_uri = (
- f"{request.scheme}://{request.get_host()}/auth/github/callback/"
- )
+ redirect_uri = f"""{"https" if request.is_secure() else "http"}://{request.get_host()}/auth/github/callback/"""
url_params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
@@ -67,6 +67,7 @@ class GitHubOAuthProvider(OauthAdapter):
self.userinfo_url,
client_secret,
code,
+ callback=callback,
)
def set_token_data(self):
diff --git a/apiserver/plane/authentication/provider/oauth/google.py b/apiserver/plane/authentication/provider/oauth/google.py
index 94a827c9d..591295cb1 100644
--- a/apiserver/plane/authentication/provider/oauth/google.py
+++ b/apiserver/plane/authentication/provider/oauth/google.py
@@ -5,12 +5,13 @@ from urllib.parse import urlencode
import pytz
-# Django imports
-from django.core.exceptions import ImproperlyConfigured
-
# Module imports
from plane.authentication.adapter.oauth import OauthAdapter
from plane.license.utils.instance_value import get_configuration_value
+from plane.authentication.adapter.error import (
+ AUTHENTICATION_ERROR_CODES,
+ AuthenticationException,
+)
class GoogleOAuthProvider(OauthAdapter):
@@ -19,7 +20,7 @@ class GoogleOAuthProvider(OauthAdapter):
scope = "https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"
provider = "google"
- def __init__(self, request, code=None, state=None):
+ def __init__(self, request, code=None, state=None, callback=None):
(GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET) = get_configuration_value(
[
{
@@ -34,16 +35,15 @@ class GoogleOAuthProvider(OauthAdapter):
)
if not (GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET):
- raise ImproperlyConfigured(
- "Google is not configured. Please contact the support team."
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["GOOGLE_NOT_CONFIGURED"],
+ error_message="GOOGLE_NOT_CONFIGURED",
)
client_id = GOOGLE_CLIENT_ID
client_secret = GOOGLE_CLIENT_SECRET
- redirect_uri = (
- f"{request.scheme}://{request.get_host()}/auth/google/callback/"
- )
+ redirect_uri = f"""{"https" if request.is_secure() else "http"}://{request.get_host()}/auth/google/callback/"""
url_params = {
"client_id": client_id,
"scope": self.scope,
@@ -66,6 +66,7 @@ class GoogleOAuthProvider(OauthAdapter):
self.userinfo_url,
client_secret,
code,
+ callback=callback,
)
def set_token_data(self):
diff --git a/apiserver/plane/authentication/urls.py b/apiserver/plane/authentication/urls.py
index 451b17e4e..4a6f8c3f4 100644
--- a/apiserver/plane/authentication/urls.py
+++ b/apiserver/plane/authentication/urls.py
@@ -7,6 +7,7 @@ from .views import (
ForgotPasswordEndpoint,
SetUserPasswordEndpoint,
ResetPasswordEndpoint,
+ ChangePasswordEndpoint,
# App
GitHubCallbackEndpoint,
GitHubOauthInitiateEndpoint,
@@ -18,6 +19,8 @@ from .views import (
SignInAuthEndpoint,
SignOutAuthEndpoint,
SignUpAuthEndpoint,
+ ForgotPasswordSpaceEndpoint,
+ ResetPasswordSpaceEndpoint,
# Space
EmailCheckEndpoint,
GitHubCallbackSpaceEndpoint,
@@ -176,6 +179,21 @@ urlpatterns = [
ResetPasswordEndpoint.as_view(),
name="forgot-password",
),
+ path(
+ "spaces/forgot-password/",
+ ForgotPasswordSpaceEndpoint.as_view(),
+ name="forgot-password",
+ ),
+ path(
+ "spaces/reset-password///",
+ ResetPasswordSpaceEndpoint.as_view(),
+ name="forgot-password",
+ ),
+ path(
+ "change-password/",
+ ChangePasswordEndpoint.as_view(),
+ name="forgot-password",
+ ),
path(
"set-password/",
SetUserPasswordEndpoint.as_view(),
diff --git a/apiserver/plane/authentication/utils/host.py b/apiserver/plane/authentication/utils/host.py
index 80f492d53..4046c1e20 100644
--- a/apiserver/plane/authentication/utils/host.py
+++ b/apiserver/plane/authentication/utils/host.py
@@ -1,10 +1,42 @@
+# Python imports
from urllib.parse import urlsplit
+# Django imports
+from django.conf import settings
-def base_host(request):
+
+def base_host(request, is_admin=False, is_space=False, is_app=False):
"""Utility function to return host / origin from the request"""
- return (
+ # Calculate the base origin from request
+ base_origin = str(
request.META.get("HTTP_ORIGIN")
or f"{urlsplit(request.META.get('HTTP_REFERER')).scheme}://{urlsplit(request.META.get('HTTP_REFERER')).netloc}"
- or f"{request.scheme}://{request.get_host()}"
+ or f"""{"https" if request.is_secure() else "http"}://{request.get_host()}"""
)
+
+ # Admin redirections
+ if is_admin:
+ if settings.ADMIN_BASE_URL:
+ return settings.ADMIN_BASE_URL
+ else:
+ return base_origin + "/god-mode/"
+
+ # Space redirections
+ if is_space:
+ if settings.SPACE_BASE_URL:
+ return settings.SPACE_BASE_URL
+ else:
+ return base_origin + "/spaces/"
+
+ # App Redirection
+ if is_app:
+ if settings.APP_BASE_URL:
+ return settings.APP_BASE_URL
+ else:
+ return base_origin
+
+ return base_origin
+
+
+def user_ip(request):
+ return str(request.META.get("REMOTE_ADDR"))
diff --git a/apiserver/plane/authentication/utils/login.py b/apiserver/plane/authentication/utils/login.py
index 7dc2eb1ca..f5d453d02 100644
--- a/apiserver/plane/authentication/utils/login.py
+++ b/apiserver/plane/authentication/utils/login.py
@@ -1,11 +1,27 @@
+# Django imports
from django.contrib.auth import login
+from django.conf import settings
+
+# Module imports
+from plane.authentication.utils.host import base_host
-def user_login(request, user):
+def user_login(request, user, is_app=False, is_admin=False, is_space=False):
login(request=request, user=user)
+
+ # If is admin cookie set the custom age
+ if is_admin:
+ request.session.set_expiry(settings.ADMIN_SESSION_COOKIE_AGE)
+
device_info = {
"user_agent": request.META.get("HTTP_USER_AGENT", ""),
"ip_address": request.META.get("REMOTE_ADDR", ""),
+ "domain": base_host(
+ request=request,
+ is_app=is_app,
+ is_admin=is_admin,
+ is_space=is_space,
+ ),
}
request.session["device_info"] = device_info
request.session.save()
diff --git a/apiserver/plane/authentication/utils/redirection_path.py b/apiserver/plane/authentication/utils/redirection_path.py
index bf9e15673..12de25cc2 100644
--- a/apiserver/plane/authentication/utils/redirection_path.py
+++ b/apiserver/plane/authentication/utils/redirection_path.py
@@ -10,10 +10,13 @@ def get_redirection_path(user):
return "onboarding"
# Redirect to the last workspace if the user has last workspace
- if profile.last_workspace_id and Workspace.objects.filter(
- pk=profile.last_workspace_id,
- workspace_member__member_id=user.id,
- workspace_member__is_active=True,
+ if (
+ profile.last_workspace_id
+ and Workspace.objects.filter(
+ pk=profile.last_workspace_id,
+ workspace_member__member_id=user.id,
+ workspace_member__is_active=True,
+ ).exists()
):
workspace = Workspace.objects.filter(
pk=profile.last_workspace_id,
diff --git a/apiserver/plane/authentication/utils/user_auth_workflow.py b/apiserver/plane/authentication/utils/user_auth_workflow.py
new file mode 100644
index 000000000..e7cb4942e
--- /dev/null
+++ b/apiserver/plane/authentication/utils/user_auth_workflow.py
@@ -0,0 +1,9 @@
+from .workspace_project_join import process_workspace_project_invitations
+
+
+def post_user_auth_workflow(
+ user,
+ is_signup,
+ request,
+):
+ process_workspace_project_invitations(user=user)
diff --git a/apiserver/plane/authentication/views/__init__.py b/apiserver/plane/authentication/views/__init__.py
index 4bd920e29..a5aadf728 100644
--- a/apiserver/plane/authentication/views/__init__.py
+++ b/apiserver/plane/authentication/views/__init__.py
@@ -1,8 +1,6 @@
from .common import (
ChangePasswordEndpoint,
CSRFTokenEndpoint,
- ForgotPasswordEndpoint,
- ResetPasswordEndpoint,
SetUserPasswordEndpoint,
)
@@ -50,3 +48,12 @@ from .space.magic import (
from .space.signout import SignOutAuthSpaceEndpoint
from .space.check import EmailCheckEndpoint
+
+from .space.password_management import (
+ ForgotPasswordSpaceEndpoint,
+ ResetPasswordSpaceEndpoint,
+)
+from .app.password_management import (
+ ForgotPasswordEndpoint,
+ ResetPasswordEndpoint,
+)
diff --git a/apiserver/plane/authentication/views/app/check.py b/apiserver/plane/authentication/views/app/check.py
index 54b39ed6f..4f164e308 100644
--- a/apiserver/plane/authentication/views/app/check.py
+++ b/apiserver/plane/authentication/views/app/check.py
@@ -1,3 +1,7 @@
+# Django imports
+from django.core.validators import validate_email
+from django.core.exceptions import ValidationError
+
# Third party imports
from rest_framework import status
from rest_framework.permissions import AllowAny
@@ -7,6 +11,10 @@ from rest_framework.views import APIView
## Module imports
from plane.db.models import User
from plane.license.models import Instance
+from plane.authentication.adapter.error import (
+ AuthenticationException,
+ AUTHENTICATION_ERROR_CODES,
+)
class EmailCheckSignUpEndpoint(APIView):
@@ -16,32 +24,60 @@ class EmailCheckSignUpEndpoint(APIView):
]
def post(self, request):
- # Check instance configuration
- instance = Instance.objects.first()
- if instance is None or not instance.is_setup_done:
- return Response(
- {
- "error_code": "INSTANCE_NOT_CONFIGURED",
- "error_message": "Instance is not configured",
- },
- status=status.HTTP_400_BAD_REQUEST,
- )
+ try:
+ # Check instance configuration
+ instance = Instance.objects.first()
+ if instance is None or not instance.is_setup_done:
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INSTANCE_NOT_CONFIGURED"
+ ],
+ error_message="INSTANCE_NOT_CONFIGURED",
+ )
+ email = request.data.get("email", False)
- email = request.data.get("email", False)
- existing_user = User.objects.filter(email=email).first()
+ # Return error if email is not present
+ if not email:
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["EMAIL_REQUIRED"],
+ error_message="EMAIL_REQUIRED",
+ )
- if existing_user:
+ # Validate email
+ validate_email(email)
+
+ existing_user = User.objects.filter(email=email).first()
+
+ if existing_user:
+ # check if the account is the deactivated
+ if not existing_user.is_active:
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "USER_ACCOUNT_DEACTIVATED"
+ ],
+ error_message="USER_ACCOUNT_DEACTIVATED",
+ )
+
+ # Raise user already exist
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "USER_ALREADY_EXIST"
+ ],
+ error_message="USER_ALREADY_EXIST",
+ )
return Response(
- {
- "error_code": "USER_ALREADY_EXIST",
- "error_message": "User already exists with the email.",
- },
- status=status.HTTP_400_BAD_REQUEST,
+ {"status": True},
+ status=status.HTTP_200_OK,
+ )
+ except ValidationError:
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
+ error_message="INVALID_EMAIL",
+ )
+ except AuthenticationException as e:
+ return Response(
+ e.get_error_dict(), status=status.HTTP_400_BAD_REQUEST
)
- return Response(
- {"status": True},
- status=status.HTTP_200_OK,
- )
class EmailCheckSignInEndpoint(APIView):
@@ -51,32 +87,61 @@ class EmailCheckSignInEndpoint(APIView):
]
def post(self, request):
- # Check instance configuration
- instance = Instance.objects.first()
- if instance is None or not instance.is_setup_done:
- return Response(
- {
- "error_code": "INSTANCE_NOT_CONFIGURED",
- "error_message": "Instance is not configured",
- },
- status=status.HTTP_400_BAD_REQUEST,
- )
+ try:
+ # Check instance configuration
+ instance = Instance.objects.first()
+ if instance is None or not instance.is_setup_done:
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INSTANCE_NOT_CONFIGURED"
+ ],
+ error_message="INSTANCE_NOT_CONFIGURED",
+ )
- email = request.data.get("email", False)
- existing_user = User.objects.filter(email=email).first()
+ email = request.data.get("email", False)
- if existing_user:
- return Response(
- {
- "status": True,
- "is_password_autoset": existing_user.is_password_autoset,
- },
- status=status.HTTP_200_OK,
+ # Return error if email is not present
+ if not email:
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["EMAIL_REQUIRED"],
+ error_message="EMAIL_REQUIRED",
+ )
+
+ # Validate email
+ validate_email(email)
+
+ existing_user = User.objects.filter(email=email).first()
+
+ # If existing user
+ if existing_user:
+ # Raise different exception when user is not active
+ if not existing_user.is_active:
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "USER_ACCOUNT_DEACTIVATED"
+ ],
+ error_message="USER_ACCOUNT_DEACTIVATED",
+ )
+ # Return true
+ return Response(
+ {
+ "status": True,
+ "is_password_autoset": existing_user.is_password_autoset,
+ },
+ status=status.HTTP_200_OK,
+ )
+
+ # Raise error
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"],
+ error_message="USER_DOES_NOT_EXIST",
+ )
+ except ValidationError:
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
+ error_message="INVALID_EMAIL",
+ )
+ except AuthenticationException as e:
+ return Response(
+ e.get_error_dict(), status=status.HTTP_400_BAD_REQUEST
)
- return Response(
- {
- "error_code": "USER_DOES_NOT_EXIST",
- "error_message": "User could not be found with the given email.",
- },
- status=status.HTTP_400_BAD_REQUEST,
- )
diff --git a/apiserver/plane/authentication/views/app/email.py b/apiserver/plane/authentication/views/app/email.py
index 894af3cbb..f21e431a4 100644
--- a/apiserver/plane/authentication/views/app/email.py
+++ b/apiserver/plane/authentication/views/app/email.py
@@ -8,16 +8,19 @@ from django.http import HttpResponseRedirect
from django.views import View
# Module imports
-from plane.authentication.adapter.base import AuthenticationException
from plane.authentication.provider.credentials.email import EmailProvider
from plane.authentication.utils.login import user_login
from plane.license.models import Instance
from plane.authentication.utils.host import base_host
from plane.authentication.utils.redirection_path import get_redirection_path
-from plane.authentication.utils.workspace_project_join import (
- process_workspace_project_invitations,
+from plane.authentication.utils.user_auth_workflow import (
+ post_user_auth_workflow,
)
from plane.db.models import User
+from plane.authentication.adapter.error import (
+ AuthenticationException,
+ AUTHENTICATION_ERROR_CODES,
+)
class SignInAuthEndpoint(View):
@@ -28,16 +31,19 @@ class SignInAuthEndpoint(View):
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
# Redirection params
- params = {
- "error_code": "REQUIRED_EMAIL_PASSWORD",
- "error_message": "Both email and password are required",
- }
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INSTANCE_NOT_CONFIGURED"
+ ],
+ error_message="INSTANCE_NOT_CONFIGURED",
+ )
+ params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
# Base URL join
url = urljoin(
- base_host(request=request),
- "accounts/sign-in?" + urlencode(params),
+ base_host(request=request, is_app=True),
+ "sign-in?" + urlencode(params),
)
return HttpResponseRedirect(url)
@@ -48,16 +54,20 @@ class SignInAuthEndpoint(View):
## Raise exception if any of the above are missing
if not email or not password:
# Redirection params
- params = {
- "error_code": "REQUIRED_EMAIL_PASSWORD",
- "error_message": "Both email and password are required",
- }
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "REQUIRED_EMAIL_PASSWORD_SIGN_IN"
+ ],
+ error_message="REQUIRED_EMAIL_PASSWORD_SIGN_IN",
+ payload={"email": str(email)},
+ )
+ params = exc.get_error_dict()
# Next path
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
- base_host(request=request),
- "accounts/sign-in?" + urlencode(params),
+ base_host(request=request, is_app=True),
+ "sign-in?" + urlencode(params),
)
return HttpResponseRedirect(url)
@@ -66,40 +76,64 @@ class SignInAuthEndpoint(View):
try:
validate_email(email)
except ValidationError:
- params = {
- "error_code": "INVALID_EMAIL",
- "error_message": "Please provide a valid email address.",
- }
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL_SIGN_IN"],
+ error_message="INVALID_EMAIL_SIGN_IN",
+ payload={"email": str(email)},
+ )
+ params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
- base_host(request=request),
- "accounts/sign-in?" + urlencode(params),
+ base_host(request=request, is_app=True),
+ "sign-in?" + urlencode(params),
)
return HttpResponseRedirect(url)
- if not User.objects.filter(email=email).exists():
- params = {
- "error_code": "USER_DOES_NOT_EXIST",
- "error_message": "User could not be found with the given email.",
- }
+ existing_user = User.objects.filter(email=email).first()
+
+ if not existing_user:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"],
+ error_message="USER_DOES_NOT_EXIST",
+ payload={"email": str(email)},
+ )
+ params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
- base_host(request=request),
- "accounts/sign-in?" + urlencode(params),
+ base_host(request=request, is_app=True),
+ "sign-in?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
+
+ if not existing_user.is_active:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "USER_ACCOUNT_DEACTIVATED"
+ ],
+ error_message="USER_ACCOUNT_DEACTIVATED",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "sign-in?" + urlencode(params),
)
return HttpResponseRedirect(url)
try:
provider = EmailProvider(
- request=request, key=email, code=password, is_signup=False
+ request=request,
+ key=email,
+ code=password,
+ is_signup=False,
+ callback=post_user_auth_workflow,
)
user = provider.authenticate()
# Login the user and record his device info
- user_login(request=request, user=user)
- # Process workspace and project invitations
- process_workspace_project_invitations(user=user)
+ user_login(request=request, user=user, is_app=True)
# Get the redirection path
if next_path:
path = str(next_path)
@@ -107,18 +141,15 @@ class SignInAuthEndpoint(View):
path = get_redirection_path(user=user)
# redirect to referer path
- url = urljoin(base_host(request=request), path)
+ url = urljoin(base_host(request=request, is_app=True), path)
return HttpResponseRedirect(url)
except AuthenticationException as e:
- params = {
- "error_code": str(e.error_code),
- "error_message": str(e.error_message),
- }
+ params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
- base_host(request=request),
- "accounts/sign-in?" + urlencode(params),
+ base_host(request=request, is_app=True),
+ "sign-in?" + urlencode(params),
)
return HttpResponseRedirect(url)
@@ -130,14 +161,18 @@ class SignUpAuthEndpoint(View):
# Check instance configuration
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
- params = {
- "error_code": "INSTANCE_NOT_CONFIGURED",
- "error_message": "Instance is not configured",
- }
+ # Redirection params
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INSTANCE_NOT_CONFIGURED"
+ ],
+ error_message="INSTANCE_NOT_CONFIGURED",
+ )
+ params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
- base_host(request=request),
+ base_host(request=request, is_app=True),
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
@@ -146,14 +181,19 @@ class SignUpAuthEndpoint(View):
password = request.POST.get("password", False)
## Raise exception if any of the above are missing
if not email or not password:
- params = {
- "error_code": "REQUIRED_EMAIL_PASSWORD",
- "error_message": "Both email and password are required",
- }
+ # Redirection params
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "REQUIRED_EMAIL_PASSWORD_SIGN_UP"
+ ],
+ error_message="REQUIRED_EMAIL_PASSWORD_SIGN_UP",
+ payload={"email": str(email)},
+ )
+ params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
- base_host(request=request),
+ base_host(request=request, is_app=True),
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
@@ -162,57 +202,81 @@ class SignUpAuthEndpoint(View):
try:
validate_email(email)
except ValidationError:
- params = {
- "error_code": "INVALID_EMAIL",
- "error_message": "Please provide a valid email address.",
- }
+ # Redirection params
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL_SIGN_UP"],
+ error_message="INVALID_EMAIL_SIGN_UP",
+ payload={"email": str(email)},
+ )
+ params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
- base_host(request=request),
+ base_host(request=request, is_app=True),
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
- if User.objects.filter(email=email).exists():
- params = {
- "error_code": "USER_ALREADY_EXIST",
- "error_message": "User already exists with the email.",
- }
+ # Existing user
+ existing_user = User.objects.filter(email=email).first()
+
+ if existing_user:
+ # Existing User
+ if not existing_user.is_active:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "USER_ACCOUNT_DEACTIVATED"
+ ],
+ error_message="USER_ACCOUNT_DEACTIVATED",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
+
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"],
+ error_message="USER_ALREADY_EXIST",
+ payload={"email": str(email)},
+ )
+ params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
- base_host(request=request),
+ base_host(request=request, is_app=True),
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
try:
provider = EmailProvider(
- request=request, key=email, code=password, is_signup=True
+ request=request,
+ key=email,
+ code=password,
+ is_signup=True,
+ callback=post_user_auth_workflow,
)
user = provider.authenticate()
# Login the user and record his device info
- user_login(request=request, user=user)
- # Process workspace and project invitations
- process_workspace_project_invitations(user=user)
+ user_login(request=request, user=user, is_app=True)
# Get the redirection path
if next_path:
path = next_path
else:
path = get_redirection_path(user=user)
# redirect to referer path
- url = urljoin(base_host(request=request), path)
+ url = urljoin(base_host(request=request, is_app=True), path)
return HttpResponseRedirect(url)
except AuthenticationException as e:
- params = {
- "error_code": str(e.error_code),
- "error_message": str(e.error_message),
- }
+ params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
- base_host(request=request),
+ base_host(request=request, is_app=True),
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
diff --git a/apiserver/plane/authentication/views/app/github.py b/apiserver/plane/authentication/views/app/github.py
index 4d299ef4f..f93beefa3 100644
--- a/apiserver/plane/authentication/views/app/github.py
+++ b/apiserver/plane/authentication/views/app/github.py
@@ -2,7 +2,6 @@ import uuid
from urllib.parse import urlencode, urljoin
# Django import
-from django.core.exceptions import ImproperlyConfigured
from django.http import HttpResponseRedirect
from django.views import View
@@ -10,18 +9,22 @@ from django.views import View
from plane.authentication.provider.oauth.github import GitHubOAuthProvider
from plane.authentication.utils.login import user_login
from plane.authentication.utils.redirection_path import get_redirection_path
-from plane.authentication.utils.workspace_project_join import (
- process_workspace_project_invitations,
+from plane.authentication.utils.user_auth_workflow import (
+ post_user_auth_workflow,
)
from plane.license.models import Instance
from plane.authentication.utils.host import base_host
+from plane.authentication.adapter.error import (
+ AuthenticationException,
+ AUTHENTICATION_ERROR_CODES,
+)
class GitHubOauthInitiateEndpoint(View):
def get(self, request):
# Get host and next path
- request.session["host"] = base_host(request=request)
+ request.session["host"] = base_host(request=request, is_app=True)
next_path = request.GET.get("next_path")
if next_path:
request.session["next_path"] = str(next_path)
@@ -29,12 +32,17 @@ class GitHubOauthInitiateEndpoint(View):
# Check instance configuration
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
- params = {
- "error_code": "INSTANCE_NOT_CONFIGURED",
- "error_message": "Instance is not configured",
- }
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INSTANCE_NOT_CONFIGURED"
+ ],
+ error_message="INSTANCE_NOT_CONFIGURED",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
url = urljoin(
- base_host(request=request),
+ base_host(request=request, is_app=True),
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
@@ -44,15 +52,12 @@ class GitHubOauthInitiateEndpoint(View):
request.session["state"] = state
auth_url = provider.get_auth_url()
return HttpResponseRedirect(auth_url)
- except ImproperlyConfigured as e:
- params = {
- "error_code": "IMPROPERLY_CONFIGURED",
- "error_message": str(e),
- }
+ except AuthenticationException as e:
+ params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
- base_host(request=request),
+ base_host(request=request, is_app=True),
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
@@ -67,10 +72,13 @@ class GitHubCallbackEndpoint(View):
next_path = request.session.get("next_path")
if state != request.session.get("state", ""):
- params = {
- "error_code": "OAUTH_PROVIDER_ERROR",
- "error_message": "State does not match",
- }
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "GITHUB_OAUTH_PROVIDER_ERROR"
+ ],
+ error_message="GITHUB_OAUTH_PROVIDER_ERROR",
+ )
+ params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
@@ -80,10 +88,13 @@ class GitHubCallbackEndpoint(View):
return HttpResponseRedirect(url)
if not code:
- params = {
- "error_code": "OAUTH_PROVIDER_ERROR",
- "error_message": "Something went wrong while fetching data from OAuth provider. Please try again after sometime.",
- }
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "GITHUB_OAUTH_PROVIDER_ERROR"
+ ],
+ error_message="GITHUB_OAUTH_PROVIDER_ERROR",
+ )
+ params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
@@ -96,12 +107,11 @@ class GitHubCallbackEndpoint(View):
provider = GitHubOAuthProvider(
request=request,
code=code,
+ callback=post_user_auth_workflow,
)
user = provider.authenticate()
# Login the user and record his device info
- user_login(request=request, user=user)
- # Process workspace and project invitations
- process_workspace_project_invitations(user=user)
+ user_login(request=request, user=user, is_app=True)
# Get the redirection path
if next_path:
path = next_path
@@ -110,11 +120,8 @@ class GitHubCallbackEndpoint(View):
# redirect to referer path
url = urljoin(base_host, path)
return HttpResponseRedirect(url)
- except ImproperlyConfigured as e:
- params = {
- "error_code": "IMPROPERLY_CONFIGURED",
- "error_message": str(e),
- }
+ except AuthenticationException as e:
+ params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
diff --git a/apiserver/plane/authentication/views/app/google.py b/apiserver/plane/authentication/views/app/google.py
index bbadc0066..05f4511e2 100644
--- a/apiserver/plane/authentication/views/app/google.py
+++ b/apiserver/plane/authentication/views/app/google.py
@@ -3,25 +3,28 @@ import uuid
from urllib.parse import urlencode, urljoin
# Django import
-from django.core.exceptions import ImproperlyConfigured
from django.http import HttpResponseRedirect
from django.views import View
+
+# Module imports
from plane.authentication.provider.oauth.google import GoogleOAuthProvider
from plane.authentication.utils.login import user_login
from plane.authentication.utils.redirection_path import get_redirection_path
-from plane.authentication.utils.workspace_project_join import (
- process_workspace_project_invitations,
+from plane.authentication.utils.user_auth_workflow import (
+ post_user_auth_workflow,
)
-
-# Module imports
from plane.license.models import Instance
from plane.authentication.utils.host import base_host
+from plane.authentication.adapter.error import (
+ AuthenticationException,
+ AUTHENTICATION_ERROR_CODES,
+)
class GoogleOauthInitiateEndpoint(View):
def get(self, request):
- request.session["host"] = base_host(request=request)
+ request.session["host"] = base_host(request=request, is_app=True)
next_path = request.GET.get("next_path")
if next_path:
request.session["next_path"] = str(next_path)
@@ -29,14 +32,17 @@ class GoogleOauthInitiateEndpoint(View):
# Check instance configuration
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
- params = {
- "error_code": "INSTANCE_NOT_CONFIGURED",
- "error_message": "Instance is not configured",
- }
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INSTANCE_NOT_CONFIGURED"
+ ],
+ error_message="INSTANCE_NOT_CONFIGURED",
+ )
+ params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
- base_host(request=request),
+ base_host(request=request, is_app=True),
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
@@ -47,15 +53,12 @@ class GoogleOauthInitiateEndpoint(View):
request.session["state"] = state
auth_url = provider.get_auth_url()
return HttpResponseRedirect(auth_url)
- except ImproperlyConfigured as e:
- params = {
- "error_code": "IMPROPERLY_CONFIGURED",
- "error_message": str(e),
- }
+ except AuthenticationException as e:
+ params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
- base_host(request=request),
+ base_host(request=request, is_app=True),
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
@@ -69,10 +72,13 @@ class GoogleCallbackEndpoint(View):
next_path = request.session.get("next_path")
if state != request.session.get("state", ""):
- params = {
- "error_code": "OAUTH_PROVIDER_ERROR",
- "error_message": "State does not match",
- }
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "GOOGLE_OAUTH_PROVIDER_ERROR"
+ ],
+ error_message="GOOGLE_OAUTH_PROVIDER_ERROR",
+ )
+ params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
@@ -81,10 +87,13 @@ class GoogleCallbackEndpoint(View):
)
return HttpResponseRedirect(url)
if not code:
- params = {
- "error_code": "OAUTH_PROVIDER_ERROR",
- "error_message": "Something went wrong while fetching data from OAuth provider. Please try again after sometime.",
- }
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "GOOGLE_OAUTH_PROVIDER_ERROR"
+ ],
+ error_message="GOOGLE_OAUTH_PROVIDER_ERROR",
+ )
+ params = exc.get_error_dict()
if next_path:
params["next_path"] = next_path
url = urljoin(
@@ -96,22 +105,18 @@ class GoogleCallbackEndpoint(View):
provider = GoogleOAuthProvider(
request=request,
code=code,
+ callback=post_user_auth_workflow,
)
user = provider.authenticate()
# Login the user and record his device info
- user_login(request=request, user=user)
- # Process workspace and project invitations
- process_workspace_project_invitations(user=user)
+ user_login(request=request, user=user, is_app=True)
# Get the redirection path
path = get_redirection_path(user=user)
# redirect to referer path
url = urljoin(base_host, str(next_path) if next_path else path)
return HttpResponseRedirect(url)
- except ImproperlyConfigured as e:
- params = {
- "error_code": "IMPROPERLY_CONFIGURED",
- "error_message": str(e),
- }
+ except AuthenticationException as e:
+ params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
diff --git a/apiserver/plane/authentication/views/app/magic.py b/apiserver/plane/authentication/views/app/magic.py
index da14acbef..bb3c72534 100644
--- a/apiserver/plane/authentication/views/app/magic.py
+++ b/apiserver/plane/authentication/views/app/magic.py
@@ -2,7 +2,6 @@
from urllib.parse import urlencode, urljoin
# Django imports
-from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.core.validators import validate_email
from django.http import HttpResponseRedirect
from django.views import View
@@ -14,19 +13,22 @@ from rest_framework.response import Response
from rest_framework.views import APIView
# Module imports
-from plane.authentication.adapter.base import AuthenticationException
from plane.authentication.provider.credentials.magic_code import (
MagicCodeProvider,
)
from plane.authentication.utils.login import user_login
from plane.authentication.utils.redirection_path import get_redirection_path
-from plane.authentication.utils.workspace_project_join import (
- process_workspace_project_invitations,
+from plane.authentication.utils.user_auth_workflow import (
+ post_user_auth_workflow,
)
from plane.bgtasks.magic_link_code_task import magic_link
from plane.license.models import Instance
from plane.authentication.utils.host import base_host
-from plane.db.models import User
+from plane.db.models import User, Profile
+from plane.authentication.adapter.error import (
+ AuthenticationException,
+ AUTHENTICATION_ERROR_CODES,
+)
class MagicGenerateEndpoint(APIView):
@@ -39,11 +41,14 @@ class MagicGenerateEndpoint(APIView):
# Check if instance is configured
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INSTANCE_NOT_CONFIGURED"
+ ],
+ error_message="INSTANCE_NOT_CONFIGURED",
+ )
return Response(
- {
- "error_code": "INSTANCE_NOT_CONFIGURED",
- "error_message": "Instance is not configured",
- }
+ exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST
)
origin = request.META.get("HTTP_ORIGIN", "/")
@@ -57,28 +62,10 @@ class MagicGenerateEndpoint(APIView):
# If the smtp is configured send through here
magic_link.delay(email, key, token, origin)
return Response({"key": str(key)}, status=status.HTTP_200_OK)
- except ImproperlyConfigured as e:
- return Response(
- {
- "error_code": "IMPROPERLY_CONFIGURED",
- "error_message": str(e),
- },
- status=status.HTTP_400_BAD_REQUEST,
- )
except AuthenticationException as e:
+ params = e.get_error_dict()
return Response(
- {
- "error_code": str(e.error_code),
- "error_message": str(e.error_message),
- },
- status=status.HTTP_400_BAD_REQUEST,
- )
- except ValidationError:
- return Response(
- {
- "error_code": "INVALID_EMAIL",
- "error_message": "Valid email is required for generating a magic code",
- },
+ params,
status=status.HTTP_400_BAD_REQUEST,
)
@@ -93,63 +80,85 @@ class MagicSignInEndpoint(View):
next_path = request.POST.get("next_path")
if code == "" or email == "":
- params = {
- "error_code": "EMAIL_CODE_REQUIRED",
- "error_message": "Email and code are required",
- }
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED"
+ ],
+ error_message="MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED",
+ )
+ params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
- base_host(request=request),
- "accounts/sign-in?" + urlencode(params),
+ base_host(request=request, is_app=True),
+ "sign-in?" + urlencode(params),
)
return HttpResponseRedirect(url)
- if not User.objects.filter(email=email).exists():
- params = {
- "error_code": "USER_DOES_NOT_EXIST",
- "error_message": "User could not be found with the given email.",
- }
+ # Existing User
+ existing_user = User.objects.filter(email=email).first()
+
+ if not existing_user:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"],
+ error_message="USER_DOES_NOT_EXIST",
+ )
+ params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
- base_host(request=request),
- "accounts/sign-in?" + urlencode(params),
+ base_host(request=request, is_app=True),
+ "sign-in?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
+
+ if not existing_user.is_active:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "USER_ACCOUNT_DEACTIVATED"
+ ],
+ error_message="USER_ACCOUNT_DEACTIVATED",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "sign-in?" + urlencode(params),
)
return HttpResponseRedirect(url)
try:
provider = MagicCodeProvider(
- request=request, key=f"magic_{email}", code=code
+ request=request,
+ key=f"magic_{email}",
+ code=code,
+ callback=post_user_auth_workflow,
)
user = provider.authenticate()
+ profile = Profile.objects.get(user=user)
# Login the user and record his device info
- user_login(request=request, user=user)
- # Process workspace and project invitations
- process_workspace_project_invitations(user=user)
- if user.is_password_autoset:
+ user_login(request=request, user=user, is_app=True)
+ if user.is_password_autoset and profile.is_onboarded:
path = "accounts/set-password"
else:
# Get the redirection path
path = (
str(next_path)
if next_path
- else str(process_workspace_project_invitations(user=user))
+ else str(get_redirection_path(user=user))
)
# redirect to referer path
- url = urljoin(base_host(request=request), path)
+ url = urljoin(base_host(request=request, is_app=True), path)
return HttpResponseRedirect(url)
except AuthenticationException as e:
- params = {
- "error_code": str(e.error_code),
- "error_message": str(e.error_message),
- }
+ params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
- base_host(request=request),
- "accounts/sign-in?" + urlencode(params),
+ base_host(request=request, is_app=True),
+ "sign-in?" + urlencode(params),
)
return HttpResponseRedirect(url)
@@ -164,58 +173,61 @@ class MagicSignUpEndpoint(View):
next_path = request.POST.get("next_path")
if code == "" or email == "":
- params = {
- "error_code": "EMAIL_CODE_REQUIRED",
- "error_message": "Email and code are required",
- }
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED"
+ ],
+ error_message="MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED",
+ )
+ params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
- base_host(request=request),
+ base_host(request=request, is_app=True),
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
-
- if User.objects.filter(email=email).exists():
- params = {
- "error_code": "USER_ALREADY_EXIST",
- "error_message": "User already exists with the email.",
- }
+ # Existing user
+ existing_user = User.objects.filter(email=email).first()
+ if existing_user:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"],
+ error_message="USER_ALREADY_EXIST",
+ )
+ params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
- base_host(request=request),
+ base_host(request=request, is_app=True),
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
try:
provider = MagicCodeProvider(
- request=request, key=f"magic_{email}", code=code
+ request=request,
+ key=f"magic_{email}",
+ code=code,
+ callback=post_user_auth_workflow,
)
user = provider.authenticate()
# Login the user and record his device info
- user_login(request=request, user=user)
- # Process workspace and project invitations
- process_workspace_project_invitations(user=user)
+ user_login(request=request, user=user, is_app=True)
# Get the redirection path
if next_path:
path = str(next_path)
else:
path = get_redirection_path(user=user)
# redirect to referer path
- url = urljoin(base_host(request=request), path)
+ url = urljoin(base_host(request=request, is_app=True), path)
return HttpResponseRedirect(url)
except AuthenticationException as e:
- params = {
- "error_code": str(e.error_code),
- "error_message": str(e.error_message),
- }
+ params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
- base_host(request=request),
+ base_host(request=request, is_app=True),
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
diff --git a/apiserver/plane/authentication/views/app/password_management.py b/apiserver/plane/authentication/views/app/password_management.py
new file mode 100644
index 000000000..dd14ceb91
--- /dev/null
+++ b/apiserver/plane/authentication/views/app/password_management.py
@@ -0,0 +1,193 @@
+# Python imports
+import os
+from urllib.parse import urlencode, urljoin
+
+# Third party imports
+from rest_framework import status
+from rest_framework.permissions import AllowAny
+from rest_framework.response import Response
+from rest_framework.views import APIView
+from zxcvbn import zxcvbn
+
+# Django imports
+from django.contrib.auth.tokens import PasswordResetTokenGenerator
+from django.core.exceptions import ValidationError
+from django.core.validators import validate_email
+from django.http import HttpResponseRedirect
+from django.utils.encoding import (
+ DjangoUnicodeDecodeError,
+ smart_bytes,
+ smart_str,
+)
+from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
+from django.views import View
+
+# Module imports
+from plane.bgtasks.forgot_password_task import forgot_password
+from plane.license.models import Instance
+from plane.db.models import User
+from plane.license.utils.instance_value import get_configuration_value
+from plane.authentication.utils.host import base_host
+from plane.authentication.adapter.error import (
+ AuthenticationException,
+ AUTHENTICATION_ERROR_CODES,
+)
+
+
+def generate_password_token(user):
+ uidb64 = urlsafe_base64_encode(smart_bytes(user.id))
+ token = PasswordResetTokenGenerator().make_token(user)
+
+ return uidb64, token
+
+
+class ForgotPasswordEndpoint(APIView):
+ permission_classes = [
+ AllowAny,
+ ]
+
+ def post(self, request):
+ email = request.data.get("email")
+
+ # Check instance configuration
+ instance = Instance.objects.first()
+ if instance is None or not instance.is_setup_done:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INSTANCE_NOT_CONFIGURED"
+ ],
+ error_message="INSTANCE_NOT_CONFIGURED",
+ )
+ return Response(
+ exc.get_error_dict(),
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ (EMAIL_HOST,) = get_configuration_value(
+ [
+ {
+ "key": "EMAIL_HOST",
+ "default": os.environ.get("EMAIL_HOST"),
+ },
+ ]
+ )
+
+ if not (EMAIL_HOST):
+ exc = AuthenticationException(
+ error_message="SMTP_NOT_CONFIGURED",
+ error_code=AUTHENTICATION_ERROR_CODES["SMTP_NOT_CONFIGURED"],
+ )
+ return Response(
+ exc.get_error_dict(),
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ try:
+ validate_email(email)
+ except ValidationError:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
+ error_message="INVALID_EMAIL",
+ )
+ return Response(
+ exc.get_error_dict(),
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # Get the user
+ user = User.objects.filter(email=email).first()
+ if user:
+ # Get the reset token for user
+ uidb64, token = generate_password_token(user=user)
+ current_site = request.META.get("HTTP_ORIGIN")
+ # send the forgot password email
+ forgot_password.delay(
+ user.first_name, user.email, uidb64, token, current_site
+ )
+ return Response(
+ {"message": "Check your email to reset your password"},
+ status=status.HTTP_200_OK,
+ )
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"],
+ error_message="USER_DOES_NOT_EXIST",
+ )
+ return Response(
+ exc.get_error_dict(),
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+
+class ResetPasswordEndpoint(View):
+
+ def post(self, request, uidb64, token):
+ try:
+ # Decode the id from the uidb64
+ id = smart_str(urlsafe_base64_decode(uidb64))
+ user = User.objects.get(id=id)
+
+ # check if the token is valid for the user
+ if not PasswordResetTokenGenerator().check_token(user, token):
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INVALID_PASSWORD_TOKEN"
+ ],
+ error_message="INVALID_PASSWORD_TOKEN",
+ )
+ params = exc.get_error_dict()
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "accounts/reset-password?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
+
+ password = request.POST.get("password", False)
+
+ if not password:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
+ error_message="INVALID_PASSWORD",
+ )
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "accounts/reset-password?"
+ + urlencode(exc.get_error_dict()),
+ )
+ return HttpResponseRedirect(url)
+
+ # Check the password complexity
+ results = zxcvbn(password)
+ if results["score"] < 3:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
+ error_message="INVALID_PASSWORD",
+ )
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "accounts/reset-password?"
+ + urlencode(exc.get_error_dict()),
+ )
+ return HttpResponseRedirect(url)
+
+ # set_password also hashes the password that the user will get
+ user.set_password(password)
+ user.is_password_autoset = False
+ user.save()
+
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "sign-in?" + urlencode({"success": True}),
+ )
+ return HttpResponseRedirect(url)
+ except DjangoUnicodeDecodeError:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "EXPIRED_PASSWORD_TOKEN"
+ ],
+ error_message="EXPIRED_PASSWORD_TOKEN",
+ )
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "accounts/reset-password?" + urlencode(exc.get_error_dict()),
+ )
+ return HttpResponseRedirect(url)
diff --git a/apiserver/plane/authentication/views/app/signout.py b/apiserver/plane/authentication/views/app/signout.py
index 46cd0fa7c..10461f240 100644
--- a/apiserver/plane/authentication/views/app/signout.py
+++ b/apiserver/plane/authentication/views/app/signout.py
@@ -1,21 +1,31 @@
# Python imports
-from urllib.parse import urlencode, urljoin
+from urllib.parse import urljoin
# Django imports
from django.views import View
from django.contrib.auth import logout
from django.http import HttpResponseRedirect
+from django.utils import timezone
# Module imports
-from plane.authentication.utils.host import base_host
+from plane.authentication.utils.host import user_ip, base_host
+from plane.db.models import User
class SignOutAuthEndpoint(View):
def post(self, request):
- logout(request)
- url = urljoin(
- base_host(request=request),
- "accounts/sign-in?" + urlencode({"success": "true"}),
- )
- return HttpResponseRedirect(url)
+ # Get user
+ try:
+ user = User.objects.get(pk=request.user.id)
+ user.last_logout_ip = user_ip(request=request)
+ user.last_logout_time = timezone.now()
+ user.save()
+ # Log the user out
+ logout(request)
+ url = urljoin(base_host(request=request, is_app=True), "sign-in")
+ return HttpResponseRedirect(url)
+ except Exception:
+ return HttpResponseRedirect(
+ base_host(request=request, is_app=True), "sign-in"
+ )
diff --git a/apiserver/plane/authentication/views/common.py b/apiserver/plane/authentication/views/common.py
index 693054596..640f744ce 100644
--- a/apiserver/plane/authentication/views/common.py
+++ b/apiserver/plane/authentication/views/common.py
@@ -1,21 +1,3 @@
-# Python imports
-import os
-from urllib.parse import urlencode, urljoin
-
-# Django imports
-from django.contrib.auth.tokens import PasswordResetTokenGenerator
-from django.core.exceptions import ValidationError
-from django.core.validators import validate_email
-from django.http import HttpResponseRedirect
-from django.middleware.csrf import get_token
-from django.utils.encoding import (
- DjangoUnicodeDecodeError,
- smart_bytes,
- smart_str,
-)
-from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
-from django.views import View
-
# Third party imports
from rest_framework import status
from rest_framework.permissions import AllowAny
@@ -25,15 +7,16 @@ from zxcvbn import zxcvbn
## Module imports
from plane.app.serializers import (
- ChangePasswordSerializer,
UserSerializer,
)
from plane.authentication.utils.login import user_login
-from plane.bgtasks.forgot_password_task import forgot_password
from plane.db.models import User
-from plane.license.models import Instance
-from plane.license.utils.instance_value import get_configuration_value
-from plane.authentication.utils.host import base_host
+from plane.authentication.adapter.error import (
+ AuthenticationException,
+ AUTHENTICATION_ERROR_CODES,
+)
+from django.middleware.csrf import get_token
+from plane.utils.cache import invalidate_cache
class CSRFTokenEndpoint(APIView):
@@ -51,235 +34,100 @@ class CSRFTokenEndpoint(APIView):
)
-def generate_password_token(user):
- uidb64 = urlsafe_base64_encode(smart_bytes(user.id))
- token = PasswordResetTokenGenerator().make_token(user)
-
- return uidb64, token
-
-
-class ForgotPasswordEndpoint(APIView):
- permission_classes = [
- AllowAny,
- ]
-
- def post(self, request):
- email = request.data.get("email")
-
- # Check instance configuration
- instance = Instance.objects.first()
- if instance is None or not instance.is_setup_done:
- return Response(
- {
- "error_code": "INSTANCE_NOT_CONFIGURED",
- "error_message": "Instance is not configured",
- },
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- (EMAIL_HOST, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD) = (
- get_configuration_value(
- [
- {
- "key": "EMAIL_HOST",
- "default": os.environ.get("EMAIL_HOST"),
- },
- {
- "key": "EMAIL_HOST_USER",
- "default": os.environ.get("EMAIL_HOST_USER"),
- },
- {
- "key": "EMAIL_HOST_PASSWORD",
- "default": os.environ.get("EMAIL_HOST_PASSWORD"),
- },
- ]
- )
- )
-
- if not (EMAIL_HOST and EMAIL_HOST_USER and EMAIL_HOST_PASSWORD):
- return Response(
- {
- "error_code": "SMTP_NOT_CONFIGURED",
- "error_message": "SMTP is not configured. Please contact your admin",
- },
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- try:
- validate_email(email)
- except ValidationError:
- return Response(
- {
- "error_code": "INVALID_EMAIL",
- "error_message": "Please enter a valid email",
- },
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- # Get the user
- user = User.objects.filter(email=email).first()
- if user:
- # Get the reset token for user
- uidb64, token = generate_password_token(user=user)
- current_site = request.META.get("HTTP_ORIGIN")
- # send the forgot password email
- forgot_password.delay(
- user.first_name, user.email, uidb64, token, current_site
- )
- return Response(
- {"message": "Check your email to reset your password"},
- status=status.HTTP_200_OK,
- )
- return Response(
- {
- "error_code": "INVALID_EMAIL",
- "error_message": "Please check the email",
- },
- status=status.HTTP_400_BAD_REQUEST,
- )
-
-
-class ResetPasswordEndpoint(View):
-
- def post(self, request, uidb64, token):
- try:
- # Decode the id from the uidb64
- id = smart_str(urlsafe_base64_decode(uidb64))
- user = User.objects.get(id=id)
-
- # check if the token is valid for the user
- if not PasswordResetTokenGenerator().check_token(user, token):
- url = urljoin(
- base_host(request=request),
- "accounts/reset-password?"
- + urlencode(
- {
- "error_code": "INVALID_TOKEN",
- "error_message": "Token is invalid",
- }
- ),
- )
- return HttpResponseRedirect(url)
-
- password = request.POST.get("password", False)
-
- if not password:
- url = urljoin(
- base_host(request=request),
- "?" + urlencode({"error": "Password is required"}),
- )
- return HttpResponseRedirect(url)
-
- # Check the password complexity
- results = zxcvbn(password)
- if results["score"] < 3:
- url = urljoin(
- base_host(request=request),
- "accounts/reset-password?"
- + urlencode(
- {
- "error_code": "INVALID_PASSWORD",
- "error_message": "The password is not a valid password",
- }
- ),
- )
- return HttpResponseRedirect(url)
-
- # set_password also hashes the password that the user will get
- user.set_password(password)
- user.is_password_autoset = False
- user.save()
-
- url = urljoin(
- base_host(request=request),
- "accounts/sign-in?" + urlencode({"success", True}),
- )
- return HttpResponseRedirect(url)
- except DjangoUnicodeDecodeError:
- url = urljoin(
- base_host(request=request),
- "accounts/reset-password?"
- + urlencode(
- {
- "error_code": "INVALID_TOKEN",
- "error_message": "The password token is not valid",
- }
- ),
- )
- return HttpResponseRedirect(url)
-
-
class ChangePasswordEndpoint(APIView):
def post(self, request):
- serializer = ChangePasswordSerializer(data=request.data)
user = User.objects.get(pk=request.user.id)
- if serializer.is_valid():
- if not user.check_password(serializer.data.get("old_password")):
- return Response(
- {"error": "Old password is not correct"},
- status=status.HTTP_400_BAD_REQUEST,
- )
- # check the password score
- results = zxcvbn(serializer.data.get("new_password"))
- if results["score"] < 3:
- return Response(
- {
- "error_code": "INVALID_PASSWORD",
- "error_message": "Invalid password.",
- },
- status=status.HTTP_400_BAD_REQUEST,
- )
+ old_password = request.data.get("old_password", False)
+ new_password = request.data.get("new_password", False)
- # set_password also hashes the password that the user will get
- user.set_password(serializer.data.get("new_password"))
- user.is_password_autoset = False
- user.save()
- user_login(user=user, request=request)
- return Response(
- {"message": "Password updated successfully"},
- status=status.HTTP_200_OK,
+ if not old_password or not new_password:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["MISSING_PASSWORD"],
+ error_message="MISSING_PASSWORD",
+ payload={"error": "Old or new password is missing"},
)
+ return Response(
+ exc.get_error_dict(),
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ if not user.check_password(old_password):
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INCORRECT_OLD_PASSWORD"
+ ],
+ error_message="INCORRECT_OLD_PASSWORD",
+ payload={"error": "Old password is not correct"},
+ )
+ return Response(
+ exc.get_error_dict(),
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # check the password score
+ results = zxcvbn(new_password)
+ if results["score"] < 3:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_NEW_PASSWORD"],
+ error_message="INVALID_NEW_PASSWORD",
+ )
+ return Response(
+ exc.get_error_dict(),
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # set_password also hashes the password that the user will get
+ user.set_password(new_password)
+ user.is_password_autoset = False
+ user.save()
+ user_login(user=user, request=request, is_app=True)
return Response(
- {
- "error_code": "INVALID_PASSWORD",
- "error_message": "Invalid passwords provided",
- },
- status=status.HTTP_400_BAD_REQUEST,
+ {"message": "Password updated successfully"},
+ status=status.HTTP_200_OK,
)
class SetUserPasswordEndpoint(APIView):
+
+ @invalidate_cache("/api/users/me/")
def post(self, request):
user = User.objects.get(pk=request.user.id)
password = request.data.get("password", False)
# If the user password is not autoset then return error
if not user.is_password_autoset:
- return Response(
- {
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_ALREADY_SET"],
+ error_message="PASSWORD_ALREADY_SET",
+ payload={
"error": "Your password is already set please change your password from profile"
},
+ )
+ return Response(
+ exc.get_error_dict(),
status=status.HTTP_400_BAD_REQUEST,
)
# Check password validation
- if not password and len(str(password)) < 8:
+ if not password:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
+ error_message="INVALID_PASSWORD",
+ )
return Response(
- {
- "error_code": "INVALID_PASSWORD",
- "error_message": "Invalid password.",
- },
+ exc.get_error_dict(),
status=status.HTTP_400_BAD_REQUEST,
)
results = zxcvbn(password)
if results["score"] < 3:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
+ error_message="INVALID_PASSWORD",
+ )
return Response(
- {
- "error_code": "INVALID_PASSWORD",
- "error_message": "Invalid password.",
- },
+ exc.get_error_dict(),
status=status.HTTP_400_BAD_REQUEST,
)
@@ -288,7 +136,7 @@ class SetUserPasswordEndpoint(APIView):
user.is_password_autoset = False
user.save()
# Login the user as the session is invalidated
- user_login(user=user, request=request)
+ user_login(user=user, request=request, is_app=True)
# Return the user
serializer = UserSerializer(user)
return Response(serializer.data, status=status.HTTP_200_OK)
diff --git a/apiserver/plane/authentication/views/space/check.py b/apiserver/plane/authentication/views/space/check.py
index 9f16cc45f..83f52e28f 100644
--- a/apiserver/plane/authentication/views/space/check.py
+++ b/apiserver/plane/authentication/views/space/check.py
@@ -1,3 +1,7 @@
+# Django imports
+from django.core.validators import validate_email
+from django.core.exceptions import ValidationError
+
# Third party imports
from rest_framework import status
from rest_framework.permissions import AllowAny
@@ -7,6 +11,10 @@ from rest_framework.views import APIView
## Module imports
from plane.db.models import User
from plane.license.models import Instance
+from plane.authentication.adapter.error import (
+ AUTHENTICATION_ERROR_CODES,
+ AuthenticationException,
+)
class EmailCheckEndpoint(APIView):
@@ -19,21 +27,58 @@ class EmailCheckEndpoint(APIView):
# Check instance configuration
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INSTANCE_NOT_CONFIGURED"
+ ],
+ error_message="INSTANCE_NOT_CONFIGURED",
+ )
return Response(
- {
- "error_code": "INSTANCE_NOT_CONFIGURED",
- "error_message": "Instance is not configured",
- },
+ exc.get_error_dict(),
status=status.HTTP_400_BAD_REQUEST,
)
email = request.data.get("email", False)
+ # Return error if email is not present
+ if not email:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["EMAIL_REQUIRED"],
+ error_message="EMAIL_REQUIRED",
+ )
+ return Response(
+ exc.get_error_dict(),
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # Validate email
+ try:
+ validate_email(email)
+ except ValidationError:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
+ error_message="INVALID_EMAIL",
+ )
+ return Response(
+ exc.get_error_dict(),
+ status=status.HTTP_400_BAD_REQUEST,
+ )
# Check if a user already exists with the given email
existing_user = User.objects.filter(email=email).first()
# If existing user
if existing_user:
+ if not existing_user.is_active:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "USER_ACCOUNT_DEACTIVATED"
+ ],
+ error_message="USER_ACCOUNT_DEACTIVATED",
+ )
+ return Response(
+ exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST
+ )
+
return Response(
{
"existing": True,
diff --git a/apiserver/plane/authentication/views/space/email.py b/apiserver/plane/authentication/views/space/email.py
index 8849fab7b..7a5613a75 100644
--- a/apiserver/plane/authentication/views/space/email.py
+++ b/apiserver/plane/authentication/views/space/email.py
@@ -1,5 +1,5 @@
# Python imports
-from urllib.parse import urlencode, urljoin
+from urllib.parse import urlencode
# Django imports
from django.core.exceptions import ValidationError
@@ -8,12 +8,15 @@ from django.http import HttpResponseRedirect
from django.views import View
# Module imports
-from plane.authentication.adapter.base import AuthenticationException
from plane.authentication.provider.credentials.email import EmailProvider
from plane.authentication.utils.login import user_login
from plane.license.models import Instance
from plane.authentication.utils.host import base_host
from plane.db.models import User
+from plane.authentication.adapter.error import (
+ AUTHENTICATION_ERROR_CODES,
+ AuthenticationException,
+)
class SignInAuthSpaceEndpoint(View):
@@ -23,16 +26,17 @@ class SignInAuthSpaceEndpoint(View):
# Check instance configuration
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
- params = {
- "error_code": "INSTANCE_NOT_CONFIGURED",
- "error_message": "Instance is not configured",
- }
+ # Redirection params
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INSTANCE_NOT_CONFIGURED"
+ ],
+ error_message="INSTANCE_NOT_CONFIGURED",
+ )
+ params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
- url = urljoin(
- base_host(request=request),
- "accounts/sign-in?" + urlencode(params),
- )
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
# set the referer as session to redirect after login
@@ -41,16 +45,17 @@ class SignInAuthSpaceEndpoint(View):
## Raise exception if any of the above are missing
if not email or not password:
- params = {
- "error_code": "REQUIRED_EMAIL_PASSWORD",
- "error_message": "Both email and password are required",
- }
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "REQUIRED_EMAIL_PASSWORD_SIGN_IN"
+ ],
+ error_message="REQUIRED_EMAIL_PASSWORD_SIGN_IN",
+ payload={"email": str(email)},
+ )
+ params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
- url = urljoin(
- base_host(request=request),
- "spaces/accounts/sign-in?" + urlencode(params),
- )
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
# Validate email
@@ -58,29 +63,43 @@ class SignInAuthSpaceEndpoint(View):
try:
validate_email(email)
except ValidationError:
- params = {
- "error_code": "INVALID_EMAIL",
- "error_message": "Please provide a valid email address.",
- }
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL_SIGN_IN"],
+ error_message="INVALID_EMAIL_SIGN_IN",
+ payload={"email": str(email)},
+ )
+ params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
- url = urljoin(
- base_host(request=request),
- "spaces/accounts/sign-in?" + urlencode(params),
- )
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
- if not User.objects.filter(email=email).exists():
- params = {
- "error_code": "USER_DOES_NOT_EXIST",
- "error_message": "User could not be found with the given email.",
- }
+ # Existing User
+ existing_user = User.objects.filter(email=email).first()
+
+ if not existing_user:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"],
+ error_message="USER_DOES_NOT_EXIST",
+ payload={"email": str(email)},
+ )
+ params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
- url = urljoin(
- base_host(request=request),
- "spaces/accounts/sign-in?" + urlencode(params),
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
+
+ if not existing_user.is_active:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "USER_ACCOUNT_DEACTIVATED"
+ ],
+ error_message="USER_ACCOUNT_DEACTIVATED",
)
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
try:
@@ -89,24 +108,15 @@ class SignInAuthSpaceEndpoint(View):
)
user = provider.authenticate()
# Login the user and record his device info
- user_login(request=request, user=user)
+ user_login(request=request, user=user, is_space=True)
# redirect to next path
- url = urljoin(
- base_host(request=request),
- str(next_path) if next_path else "/",
- )
+ url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
return HttpResponseRedirect(url)
except AuthenticationException as e:
- params = {
- "error_code": str(e.error_code),
- "error_message": str(e.error_message),
- }
+ params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
- url = urljoin(
- base_host(request=request),
- "spaces/accounts/sign-in?" + urlencode(params),
- )
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -117,61 +127,79 @@ class SignUpAuthSpaceEndpoint(View):
# Check instance configuration
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
- params = {
- "error_code": "INSTANCE_NOT_CONFIGURED",
- "error_message": "Instance is not configured",
- }
+ # Redirection params
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INSTANCE_NOT_CONFIGURED"
+ ],
+ error_message="INSTANCE_NOT_CONFIGURED",
+ )
+ params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
- url = urljoin(
- base_host(request=request),
- "spaces?" + urlencode(params),
- )
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
email = request.POST.get("email", False)
password = request.POST.get("password", False)
## Raise exception if any of the above are missing
if not email or not password:
- params = {
- "error_code": "REQUIRED_EMAIL_PASSWORD",
- "error_message": "Both email and password are required",
- }
+ # Redirection params
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "REQUIRED_EMAIL_PASSWORD_SIGN_UP"
+ ],
+ error_message="REQUIRED_EMAIL_PASSWORD_SIGN_UP",
+ payload={"email": str(email)},
+ )
+ params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
- url = urljoin(
- base_host(request=request),
- "spaces?" + urlencode(params),
- )
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
# Validate the email
email = email.strip().lower()
try:
validate_email(email)
except ValidationError:
- params = {
- "error_code": "INVALID_EMAIL",
- "error_message": "Please provide a valid email address.",
- }
+ # Redirection params
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL_SIGN_UP"],
+ error_message="INVALID_EMAIL_SIGN_UP",
+ payload={"email": str(email)},
+ )
+ params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
- url = urljoin(
- base_host(request=request),
- "spaces?" + urlencode(params),
- )
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
- if User.objects.filter(email=email).exists():
- params = {
- "error_code": "USER_ALREADY_EXIST",
- "error_message": "User already exists with the email.",
- }
+ # Existing User
+ existing_user = User.objects.filter(email=email).first()
+
+ if existing_user:
+ if not existing_user.is_active:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "USER_ACCOUNT_DEACTIVATED"
+ ],
+ error_message="USER_ACCOUNT_DEACTIVATED",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
+
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"],
+ error_message="USER_ALREADY_EXIST",
+ payload={"email": str(email)},
+ )
+ params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
- url = urljoin(
- base_host(request=request),
- "spaces?" + urlencode(params),
- )
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
try:
@@ -180,22 +208,13 @@ class SignUpAuthSpaceEndpoint(View):
)
user = provider.authenticate()
# Login the user and record his device info
- user_login(request=request, user=user)
+ user_login(request=request, user=user, is_space=True)
# redirect to referer path
- url = urljoin(
- base_host(request=request),
- str(next_path) if next_path else "spaces",
- )
+ url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
return HttpResponseRedirect(url)
except AuthenticationException as e:
- params = {
- "error_code": str(e.error_code),
- "error_message": str(e.error_message),
- }
+ params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
- url = urljoin(
- base_host(request=request),
- "spaces?" + urlencode(params),
- )
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
diff --git a/apiserver/plane/authentication/views/space/github.py b/apiserver/plane/authentication/views/space/github.py
index 082d1578f..711f7eaa7 100644
--- a/apiserver/plane/authentication/views/space/github.py
+++ b/apiserver/plane/authentication/views/space/github.py
@@ -1,9 +1,8 @@
# Python imports
import uuid
-from urllib.parse import urlencode, urljoin
+from urllib.parse import urlencode
# Django import
-from django.core.exceptions import ImproperlyConfigured
from django.http import HttpResponseRedirect
from django.views import View
@@ -12,13 +11,17 @@ from plane.authentication.provider.oauth.github import GitHubOAuthProvider
from plane.authentication.utils.login import user_login
from plane.license.models import Instance
from plane.authentication.utils.host import base_host
+from plane.authentication.adapter.error import (
+ AUTHENTICATION_ERROR_CODES,
+ AuthenticationException,
+)
class GitHubOauthInitiateSpaceEndpoint(View):
def get(self, request):
# Get host and next path
- request.session["host"] = base_host(request=request)
+ request.session["host"] = base_host(request=request, is_space=True)
next_path = request.GET.get("next_path")
if next_path:
request.session["next_path"] = str(next_path)
@@ -26,16 +29,16 @@ class GitHubOauthInitiateSpaceEndpoint(View):
# Check instance configuration
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
- params = {
- "error_code": "INSTANCE_NOT_CONFIGURED",
- "error_message": "Instance is not configured",
- }
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INSTANCE_NOT_CONFIGURED"
+ ],
+ error_message="INSTANCE_NOT_CONFIGURED",
+ )
+ params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
- url = urljoin(
- base_host(request=request),
- "?" + urlencode(params),
- )
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
try:
@@ -44,17 +47,11 @@ class GitHubOauthInitiateSpaceEndpoint(View):
request.session["state"] = state
auth_url = provider.get_auth_url()
return HttpResponseRedirect(auth_url)
- except ImproperlyConfigured as e:
- params = {
- "error_code": "IMPROPERLY_CONFIGURED",
- "error_message": str(e),
- }
+ except AuthenticationException as e:
+ params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
- url = urljoin(
- base_host(request=request),
- "?" + urlencode(params),
- )
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -67,29 +64,29 @@ class GitHubCallbackSpaceEndpoint(View):
next_path = request.session.get("next_path")
if state != request.session.get("state", ""):
- params = {
- "error_code": "OAUTH_PROVIDER_ERROR",
- "error_message": "State does not match",
- }
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "GITHUB_OAUTH_PROVIDER_ERROR"
+ ],
+ error_message="GITHUB_OAUTH_PROVIDER_ERROR",
+ )
+ params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
- url = urljoin(
- base_host,
- "?" + urlencode(params),
- )
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
if not code:
- params = {
- "error_code": "OAUTH_PROVIDER_ERROR",
- "error_message": "Something went wrong while fetching data from OAuth provider. Please try again after sometime.",
- }
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "GITHUB_OAUTH_PROVIDER_ERROR"
+ ],
+ error_message="GITHUB_OAUTH_PROVIDER_ERROR",
+ )
+ params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
- url = urljoin(
- base_host,
- "?" + urlencode(params),
- )
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
try:
@@ -99,20 +96,14 @@ class GitHubCallbackSpaceEndpoint(View):
)
user = provider.authenticate()
# Login the user and record his device info
- user_login(request=request, user=user)
+ user_login(request=request, user=user, is_space=True)
# Process workspace and project invitations
# redirect to referer path
- url = urljoin(base_host, str(next_path) if next_path else "/")
+ url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
return HttpResponseRedirect(url)
- except ImproperlyConfigured as e:
- params = {
- "error_code": "IMPROPERLY_CONFIGURED",
- "error_message": str(e),
- }
+ except AuthenticationException as e:
+ params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
- url = urljoin(
- base_host,
- "?" + urlencode(params),
- )
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
diff --git a/apiserver/plane/authentication/views/space/google.py b/apiserver/plane/authentication/views/space/google.py
index 354d73078..38a2b910a 100644
--- a/apiserver/plane/authentication/views/space/google.py
+++ b/apiserver/plane/authentication/views/space/google.py
@@ -1,24 +1,25 @@
# Python imports
import uuid
-from urllib.parse import urlencode, urljoin
+from urllib.parse import urlencode
# Django import
-from django.core.exceptions import ImproperlyConfigured
from django.http import HttpResponseRedirect
from django.views import View
+# Module imports
from plane.authentication.provider.oauth.google import GoogleOAuthProvider
from plane.authentication.utils.login import user_login
-
-
-# Module imports
from plane.license.models import Instance
from plane.authentication.utils.host import base_host
+from plane.authentication.adapter.error import (
+ AuthenticationException,
+ AUTHENTICATION_ERROR_CODES,
+)
class GoogleOauthInitiateSpaceEndpoint(View):
def get(self, request):
- request.session["host"] = base_host(request=request)
+ request.session["host"] = base_host(request=request, is_space=True)
next_path = request.GET.get("next_path")
if next_path:
request.session["next_path"] = str(next_path)
@@ -26,16 +27,16 @@ class GoogleOauthInitiateSpaceEndpoint(View):
# Check instance configuration
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
- params = {
- "error_code": "INSTANCE_NOT_CONFIGURED",
- "error_message": "Instance is not configured",
- }
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INSTANCE_NOT_CONFIGURED"
+ ],
+ error_message="INSTANCE_NOT_CONFIGURED",
+ )
+ params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
- url = urljoin(
- base_host(request=request),
- "?" + urlencode(params),
- )
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
try:
@@ -44,17 +45,11 @@ class GoogleOauthInitiateSpaceEndpoint(View):
request.session["state"] = state
auth_url = provider.get_auth_url()
return HttpResponseRedirect(auth_url)
- except ImproperlyConfigured as e:
- params = {
- "error_code": "IMPROPERLY_CONFIGURED",
- "error_message": str(e),
- }
+ except AuthenticationException as e:
+ params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
- url = urljoin(
- base_host(request=request),
- "?" + urlencode(params),
- )
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -66,28 +61,28 @@ class GoogleCallbackSpaceEndpoint(View):
next_path = request.session.get("next_path")
if state != request.session.get("state", ""):
- params = {
- "error_code": "OAUTH_PROVIDER_ERROR",
- "error_message": "State does not match",
- }
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "GOOGLE_OAUTH_PROVIDER_ERROR"
+ ],
+ error_message="GOOGLE_OAUTH_PROVIDER_ERROR",
+ )
+ params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
- url = urljoin(
- base_host,
- "?" + urlencode(params),
- )
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
if not code:
- params = {
- "error_code": "OAUTH_PROVIDER_ERROR",
- "error_message": "Something went wrong while fetching data from OAuth provider. Please try again after sometime.",
- }
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "GOOGLE_OAUTH_PROVIDER_ERROR"
+ ],
+ error_message="GOOGLE_OAUTH_PROVIDER_ERROR",
+ )
+ params = exc.get_error_dict()
if next_path:
params["next_path"] = next_path
- url = urljoin(
- base_host,
- "?" + urlencode(params),
- )
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
try:
provider = GoogleOAuthProvider(
@@ -96,21 +91,13 @@ class GoogleCallbackSpaceEndpoint(View):
)
user = provider.authenticate()
# Login the user and record his device info
- user_login(request=request, user=user)
+ user_login(request=request, user=user, is_space=True)
# redirect to referer path
- url = urljoin(
- base_host, str(next_path) if next_path else "/spaces"
- )
+ url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
return HttpResponseRedirect(url)
- except ImproperlyConfigured as e:
- params = {
- "error_code": "IMPROPERLY_CONFIGURED",
- "error_message": str(e),
- }
+ except AuthenticationException as e:
+ params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
- url = urljoin(
- base_host,
- "?" + urlencode(params),
- )
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
diff --git a/apiserver/plane/authentication/views/space/magic.py b/apiserver/plane/authentication/views/space/magic.py
index bef7154cf..0e859d44d 100644
--- a/apiserver/plane/authentication/views/space/magic.py
+++ b/apiserver/plane/authentication/views/space/magic.py
@@ -1,8 +1,7 @@
# Python imports
-from urllib.parse import urlencode, urljoin
+from urllib.parse import urlencode
# Django imports
-from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.core.validators import validate_email
from django.http import HttpResponseRedirect
from django.views import View
@@ -14,7 +13,6 @@ from rest_framework.response import Response
from rest_framework.views import APIView
# Module imports
-from plane.authentication.adapter.base import AuthenticationException
from plane.authentication.provider.credentials.magic_code import (
MagicCodeProvider,
)
@@ -22,7 +20,11 @@ from plane.authentication.utils.login import user_login
from plane.bgtasks.magic_link_code_task import magic_link
from plane.license.models import Instance
from plane.authentication.utils.host import base_host
-from plane.db.models import User
+from plane.db.models import User, Profile
+from plane.authentication.adapter.error import (
+ AuthenticationException,
+ AUTHENTICATION_ERROR_CODES,
+)
class MagicGenerateSpaceEndpoint(APIView):
@@ -35,14 +37,17 @@ class MagicGenerateSpaceEndpoint(APIView):
# Check if instance is configured
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INSTANCE_NOT_CONFIGURED"
+ ],
+ error_message="INSTANCE_NOT_CONFIGURED",
+ )
return Response(
- {
- "error_code": "INSTANCE_NOT_CONFIGURED",
- "error_message": "Instance is not configured",
- }
+ exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST
)
- origin = base_host(request=request)
+ origin = base_host(request=request, is_space=True)
email = request.data.get("email", False)
try:
# Clean up the email
@@ -53,28 +58,9 @@ class MagicGenerateSpaceEndpoint(APIView):
# If the smtp is configured send through here
magic_link.delay(email, key, token, origin)
return Response({"key": str(key)}, status=status.HTTP_200_OK)
- except ImproperlyConfigured as e:
- return Response(
- {
- "error_code": "IMPROPERLY_CONFIGURED",
- "error_message": str(e),
- },
- status=status.HTTP_400_BAD_REQUEST,
- )
except AuthenticationException as e:
return Response(
- {
- "error_code": str(e.error_code),
- "error_message": str(e.error_message),
- },
- status=status.HTTP_400_BAD_REQUEST,
- )
- except ValidationError:
- return Response(
- {
- "error_code": "INVALID_EMAIL",
- "error_message": "Valid email is required for generating a magic code",
- },
+ e.get_error_dict(),
status=status.HTTP_400_BAD_REQUEST,
)
@@ -89,56 +75,66 @@ class MagicSignInSpaceEndpoint(View):
next_path = request.POST.get("next_path")
if code == "" or email == "":
- params = {
- "error_code": "EMAIL_CODE_REQUIRED",
- "error_message": "Email and code are required",
- }
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED"
+ ],
+ error_message="MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED",
+ )
+ params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
- url = urljoin(
- base_host(request=request),
- "spaces/accounts/sign-in?" + urlencode(params),
- )
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
- if not User.objects.filter(email=email).exists():
- params = {
- "error_code": "USER_DOES_NOT_EXIST",
- "error_message": "User could not be found with the given email.",
- }
+ existing_user = User.objects.filter(email=email).first()
+
+ if not existing_user:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"],
+ error_message="USER_DOES_NOT_EXIST",
+ )
+ params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
- url = urljoin(
- base_host(request=request),
- "accounts/sign-in?" + urlencode(params),
- )
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
+ # Active User
+ if not existing_user.is_active:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "USER_ACCOUNT_DEACTIVATED"
+ ],
+ error_message="USER_ACCOUNT_DEACTIVATED",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
try:
provider = MagicCodeProvider(
request=request, key=f"magic_{email}", code=code
)
user = provider.authenticate()
# Login the user and record his device info
- user_login(request=request, user=user)
+ user_login(request=request, user=user, is_space=True)
# redirect to referer path
- url = urljoin(
- base_host(request=request),
- str(next_path) if next_path else "spaces",
- )
+ profile = Profile.objects.get(user=user)
+ if user.is_password_autoset and profile.is_onboarded:
+ path = "accounts/set-password"
+ else:
+ # Get the redirection path
+ path = str(next_path) if next_path else ""
+ url = f"{base_host(request=request, is_space=True)}{path}"
return HttpResponseRedirect(url)
except AuthenticationException as e:
- params = {
- "error_code": str(e.error_code),
- "error_message": str(e.error_message),
- }
+ params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
- url = urljoin(
- base_host(request=request),
- "spaces/accounts/sign-in?" + urlencode(params),
- )
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -152,29 +148,29 @@ class MagicSignUpSpaceEndpoint(View):
next_path = request.POST.get("next_path")
if code == "" or email == "":
- params = {
- "error_code": "EMAIL_CODE_REQUIRED",
- "error_message": "Email and code are required",
- }
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED"
+ ],
+ error_message="MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED",
+ )
+ params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
- url = urljoin(
- base_host(request=request),
- "spaces/accounts/sign-in?" + urlencode(params),
- )
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
-
- if User.objects.filter(email=email).exists():
- params = {
- "error_code": "USER_ALREADY_EXIST",
- "error_message": "User already exists with the email.",
- }
+ # Existing User
+ existing_user = User.objects.filter(email=email).first()
+ # Already existing
+ if existing_user:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"],
+ error_message="USER_ALREADY_EXIST",
+ )
+ params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
- url = urljoin(
- base_host(request=request),
- "?" + urlencode(params),
- )
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
try:
@@ -183,23 +179,14 @@ class MagicSignUpSpaceEndpoint(View):
)
user = provider.authenticate()
# Login the user and record his device info
- user_login(request=request, user=user)
+ user_login(request=request, user=user, is_space=True)
# redirect to referer path
- url = urljoin(
- base_host(request=request),
- str(next_path) if next_path else "spaces",
- )
+ url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
return HttpResponseRedirect(url)
except AuthenticationException as e:
- params = {
- "error_code": str(e.error_code),
- "error_message": str(e.error_message),
- }
+ params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
- url = urljoin(
- base_host(request=request),
- "spaces/accounts/sign-in?" + urlencode(params),
- )
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
diff --git a/apiserver/plane/authentication/views/space/password_management.py b/apiserver/plane/authentication/views/space/password_management.py
new file mode 100644
index 000000000..fa20fa618
--- /dev/null
+++ b/apiserver/plane/authentication/views/space/password_management.py
@@ -0,0 +1,187 @@
+# Python imports
+import os
+from urllib.parse import urlencode
+
+# Third party imports
+from rest_framework import status
+from rest_framework.permissions import AllowAny
+from rest_framework.response import Response
+from rest_framework.views import APIView
+from zxcvbn import zxcvbn
+
+# Django imports
+from django.contrib.auth.tokens import PasswordResetTokenGenerator
+from django.core.exceptions import ValidationError
+from django.core.validators import validate_email
+from django.http import HttpResponseRedirect
+from django.utils.encoding import (
+ DjangoUnicodeDecodeError,
+ smart_bytes,
+ smart_str,
+)
+from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
+from django.views import View
+
+# Module imports
+from plane.bgtasks.forgot_password_task import forgot_password
+from plane.license.models import Instance
+from plane.db.models import User
+from plane.license.utils.instance_value import get_configuration_value
+from plane.authentication.utils.host import base_host
+from plane.authentication.adapter.error import (
+ AuthenticationException,
+ AUTHENTICATION_ERROR_CODES,
+)
+
+
+def generate_password_token(user):
+ uidb64 = urlsafe_base64_encode(smart_bytes(user.id))
+ token = PasswordResetTokenGenerator().make_token(user)
+
+ return uidb64, token
+
+
+class ForgotPasswordSpaceEndpoint(APIView):
+ permission_classes = [
+ AllowAny,
+ ]
+
+ def post(self, request):
+ email = request.data.get("email")
+
+ # Check instance configuration
+ instance = Instance.objects.first()
+ if instance is None or not instance.is_setup_done:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INSTANCE_NOT_CONFIGURED"
+ ],
+ error_message="INSTANCE_NOT_CONFIGURED",
+ )
+ return Response(
+ exc.get_error_dict(),
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ (EMAIL_HOST, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD) = (
+ get_configuration_value(
+ [
+ {
+ "key": "EMAIL_HOST",
+ "default": os.environ.get("EMAIL_HOST"),
+ },
+ {
+ "key": "EMAIL_HOST_USER",
+ "default": os.environ.get("EMAIL_HOST_USER"),
+ },
+ {
+ "key": "EMAIL_HOST_PASSWORD",
+ "default": os.environ.get("EMAIL_HOST_PASSWORD"),
+ },
+ ]
+ )
+ )
+
+ if not (EMAIL_HOST):
+ exc = AuthenticationException(
+ error_message="SMTP_NOT_CONFIGURED",
+ error_code=AUTHENTICATION_ERROR_CODES["SMTP_NOT_CONFIGURED"],
+ )
+ return Response(
+ exc.get_error_dict(),
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ try:
+ validate_email(email)
+ except ValidationError:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
+ error_message="INVALID_EMAIL",
+ )
+ return Response(
+ exc.get_error_dict(),
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # Get the user
+ user = User.objects.filter(email=email).first()
+ if user:
+ # Get the reset token for user
+ uidb64, token = generate_password_token(user=user)
+ current_site = request.META.get("HTTP_ORIGIN")
+ # send the forgot password email
+ forgot_password.delay(
+ user.first_name, user.email, uidb64, token, current_site
+ )
+ return Response(
+ {"message": "Check your email to reset your password"},
+ status=status.HTTP_200_OK,
+ )
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"],
+ error_message="USER_DOES_NOT_EXIST",
+ )
+ return Response(
+ exc.get_error_dict(),
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+
+class ResetPasswordSpaceEndpoint(View):
+
+ def post(self, request, uidb64, token):
+ try:
+ # Decode the id from the uidb64
+ id = smart_str(urlsafe_base64_decode(uidb64))
+ user = User.objects.get(id=id)
+
+ # check if the token is valid for the user
+ if not PasswordResetTokenGenerator().check_token(user, token):
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INVALID_PASSWORD_TOKEN"
+ ],
+ error_message="INVALID_PASSWORD_TOKEN",
+ )
+ params = exc.get_error_dict()
+ url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(params)}"
+ return HttpResponseRedirect(url)
+
+ password = request.POST.get("password", False)
+
+ if not password:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
+ error_message="INVALID_PASSWORD",
+ )
+ url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(exc.get_error_dict())}"
+ return HttpResponseRedirect(url)
+
+ # Check the password complexity
+ results = zxcvbn(password)
+ if results["score"] < 3:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
+ error_message="INVALID_PASSWORD",
+ )
+ url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(exc.get_error_dict())}"
+ return HttpResponseRedirect(url)
+
+ # set_password also hashes the password that the user will get
+ user.set_password(password)
+ user.is_password_autoset = False
+ user.save()
+
+ return HttpResponseRedirect(
+ base_host(request=request, is_space=True)
+ )
+ except DjangoUnicodeDecodeError:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "EXPIRED_PASSWORD_TOKEN"
+ ],
+ error_message="EXPIRED_PASSWORD_TOKEN",
+ )
+ url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(exc.get_error_dict())}"
+ return HttpResponseRedirect(url)
diff --git a/apiserver/plane/authentication/views/space/signout.py b/apiserver/plane/authentication/views/space/signout.py
index 622715ebf..d3f29bd8d 100644
--- a/apiserver/plane/authentication/views/space/signout.py
+++ b/apiserver/plane/authentication/views/space/signout.py
@@ -1,21 +1,29 @@
-# Python imports
-from urllib.parse import urlencode, urljoin
-
# Django imports
from django.views import View
from django.contrib.auth import logout
from django.http import HttpResponseRedirect
+from django.utils import timezone
# Module imports
-from plane.authentication.utils.host import base_host
+from plane.authentication.utils.host import base_host, user_ip
+from plane.db.models import User
class SignOutAuthSpaceEndpoint(View):
def post(self, request):
- logout(request)
- url = urljoin(
- base_host(request=request),
- "spaces/accounts/sign-in?" + urlencode({"success": "true"}),
- )
- return HttpResponseRedirect(url)
+ next_path = request.POST.get("next_path")
+
+ # Get user
+ try:
+ user = User.objects.get(pk=request.user.id)
+ user.last_logout_ip = user_ip(request=request)
+ user.last_logout_time = timezone.now()
+ user.save()
+ # Log the user out
+ logout(request)
+ url = f"{base_host(request=request, is_space=True)}{next_path}"
+ return HttpResponseRedirect(url)
+ except Exception:
+ url = f"{base_host(request=request, is_space=True)}{next_path}"
+ return HttpResponseRedirect(url)
diff --git a/apiserver/plane/bgtasks/api_logs_task.py b/apiserver/plane/bgtasks/api_logs_task.py
new file mode 100644
index 000000000..038b939d5
--- /dev/null
+++ b/apiserver/plane/bgtasks/api_logs_task.py
@@ -0,0 +1,15 @@
+from django.utils import timezone
+from datetime import timedelta
+from plane.db.models import APIActivityLog
+from celery import shared_task
+
+
+@shared_task
+def delete_api_logs():
+ # Get the logs older than 30 days to delete
+ logs_to_delete = APIActivityLog.objects.filter(
+ created_at__lte=timezone.now() - timedelta(days=30)
+ )
+
+ # Delete the logs
+ logs_to_delete._raw_delete(logs_to_delete.db)
diff --git a/apiserver/plane/bgtasks/email_notification_task.py b/apiserver/plane/bgtasks/email_notification_task.py
index 050f522c3..fa154828b 100644
--- a/apiserver/plane/bgtasks/email_notification_task.py
+++ b/apiserver/plane/bgtasks/email_notification_task.py
@@ -152,7 +152,7 @@ def process_mention(mention_component):
soup = BeautifulSoup(mention_component, "html.parser")
mentions = soup.find_all("mention-component")
for mention in mentions:
- user_id = mention["id"]
+ user_id = mention["entity_identifier"]
user = User.objects.get(pk=user_id)
user_name = user.display_name
highlighted_name = f"@{user_name}"
diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py
index 2d55d5579..007b3e48c 100644
--- a/apiserver/plane/bgtasks/issue_activites_task.py
+++ b/apiserver/plane/bgtasks/issue_activites_task.py
@@ -31,6 +31,7 @@ from plane.db.models import (
)
from plane.settings.redis import redis_instance
from plane.utils.exception_logger import log_exception
+from plane.bgtasks.webhook_task import webhook_activity
# Track Changes in name
@@ -1296,7 +1297,7 @@ def create_issue_vote_activity(
IssueActivity(
issue_id=issue_id,
actor_id=actor_id,
- verb="created",
+ verb="updated",
old_value=None,
new_value=requested_data.get("vote"),
field="vote",
@@ -1365,7 +1366,7 @@ def create_issue_relation_activity(
IssueActivity(
issue_id=issue_id,
actor_id=actor_id,
- verb="created",
+ verb="updated",
old_value="",
new_value=f"{issue.project.identifier}-{issue.sequence_id}",
field=requested_data.get("relation_type"),
@@ -1380,7 +1381,7 @@ def create_issue_relation_activity(
IssueActivity(
issue_id=related_issue,
actor_id=actor_id,
- verb="created",
+ verb="updated",
old_value="",
new_value=f"{issue.project.identifier}-{issue.sequence_id}",
field=(
@@ -1606,6 +1607,7 @@ def issue_activity(
subscriber=True,
notification=False,
origin=None,
+ inbox=None,
):
try:
issue_activities = []
@@ -1692,6 +1694,41 @@ def issue_activity(
except Exception as e:
log_exception(e)
+ for activity in issue_activities_created:
+ webhook_activity.delay(
+ event=(
+ "issue_comment"
+ if activity.field == "comment"
+ else "inbox_issue" if inbox else "issue"
+ ),
+ event_id=(
+ activity.issue_comment_id
+ if activity.field == "comment"
+ else inbox if inbox else activity.issue_id
+ ),
+ verb=activity.verb,
+ field=(
+ "description"
+ if activity.field == "comment"
+ else activity.field
+ ),
+ old_value=(
+ activity.old_value
+ if activity.old_value != ""
+ else None
+ ),
+ new_value=(
+ activity.new_value
+ if activity.new_value != ""
+ else None
+ ),
+ actor_id=activity.actor_id,
+ current_site=origin,
+ slug=activity.workspace.slug,
+ old_identifier=activity.old_identifier,
+ new_identifier=activity.new_identifier,
+ )
+
if notification:
notifications.delay(
type=type,
diff --git a/apiserver/plane/bgtasks/notification_task.py b/apiserver/plane/bgtasks/notification_task.py
index 5725abc62..9dfd0c16d 100644
--- a/apiserver/plane/bgtasks/notification_task.py
+++ b/apiserver/plane/bgtasks/notification_task.py
@@ -128,7 +128,7 @@ def extract_mentions(issue_instance):
"mention-component", attrs={"target": "users"}
)
- mentions = [mention_tag["id"] for mention_tag in mention_tags]
+ mentions = [mention_tag["entity_identifier"] for mention_tag in mention_tags]
return list(set(mentions))
except Exception:
@@ -144,7 +144,7 @@ def extract_comment_mentions(comment_value):
"mention-component", attrs={"target": "users"}
)
for mention_tag in mentions_tags:
- mentions.append(mention_tag["id"])
+ mentions.append(mention_tag["entity_identifier"])
return list(set(mentions))
except Exception:
return []
@@ -663,9 +663,7 @@ def notifications(
"old_value": str(
last_activity.old_value
),
- "activity_time": issue_activity.get(
- "created_at"
- ),
+ "activity_time": str(last_activity.created_at),
},
},
)
diff --git a/apiserver/plane/bgtasks/webhook_task.py b/apiserver/plane/bgtasks/webhook_task.py
index 5ee0244c7..6696a569c 100644
--- a/apiserver/plane/bgtasks/webhook_task.py
+++ b/apiserver/plane/bgtasks/webhook_task.py
@@ -15,6 +15,7 @@ from django.core.mail import EmailMultiAlternatives, get_connection
from django.core.serializers.json import DjangoJSONEncoder
from django.template.loader import render_to_string
from django.utils.html import strip_tags
+from django.core.exceptions import ObjectDoesNotExist
# Module imports
from plane.api.serializers import (
@@ -25,6 +26,8 @@ from plane.api.serializers import (
ModuleIssueSerializer,
ModuleSerializer,
ProjectSerializer,
+ UserLiteSerializer,
+ InboxIssueSerializer,
)
from plane.db.models import (
Cycle,
@@ -37,6 +40,7 @@ from plane.db.models import (
User,
Webhook,
WebhookLog,
+ InboxIssue,
)
from plane.license.utils.instance_value import get_email_configuration
from plane.utils.exception_logger import log_exception
@@ -49,6 +53,8 @@ SERIALIZER_MAPPER = {
"cycle_issue": CycleIssueSerializer,
"module_issue": ModuleIssueSerializer,
"issue_comment": IssueCommentSerializer,
+ "user": UserLiteSerializer,
+ "inbox_issue": InboxIssueSerializer,
}
MODEL_MAPPER = {
@@ -59,6 +65,8 @@ MODEL_MAPPER = {
"cycle_issue": CycleIssue,
"module_issue": ModuleIssue,
"issue_comment": IssueComment,
+ "user": User,
+ "inbox_issue": InboxIssue,
}
@@ -179,64 +187,6 @@ def webhook_task(self, webhook, slug, event, event_data, action, current_site):
return
-@shared_task()
-def send_webhook(event, payload, kw, action, slug, bulk, current_site):
- try:
- webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True)
-
- if event == "project":
- webhooks = webhooks.filter(project=True)
-
- if event == "issue":
- webhooks = webhooks.filter(issue=True)
-
- if event == "module" or event == "module_issue":
- webhooks = webhooks.filter(module=True)
-
- if event == "cycle" or event == "cycle_issue":
- webhooks = webhooks.filter(cycle=True)
-
- if event == "issue_comment":
- webhooks = webhooks.filter(issue_comment=True)
-
- if webhooks:
- if action in ["POST", "PATCH"]:
- if bulk and event in ["cycle_issue", "module_issue"]:
- return
- else:
- event_data = [
- get_model_data(
- event=event,
- event_id=(
- payload.get("id")
- if isinstance(payload, dict)
- else kw.get("pk")
- ),
- many=False,
- )
- ]
-
- if action == "DELETE":
- event_data = [{"id": kw.get("pk")}]
-
- for webhook in webhooks:
- for data in event_data:
- webhook_task.delay(
- webhook=webhook.id,
- slug=slug,
- event=event,
- event_data=data,
- action=action,
- current_site=current_site,
- )
-
- except Exception as e:
- if settings.DEBUG:
- print(e)
- log_exception(e)
- return
-
-
@shared_task
def send_webhook_deactivation_email(
webhook_id, receiver_id, current_site, reason
@@ -294,3 +244,245 @@ def send_webhook_deactivation_email(
except Exception as e:
log_exception(e)
return
+
+
+@shared_task(
+ bind=True,
+ autoretry_for=(requests.RequestException,),
+ retry_backoff=600,
+ max_retries=5,
+ retry_jitter=True,
+)
+def webhook_send_task(
+ self,
+ webhook,
+ slug,
+ event,
+ event_data,
+ action,
+ current_site,
+ activity,
+):
+ try:
+ webhook = Webhook.objects.get(id=webhook, workspace__slug=slug)
+
+ headers = {
+ "Content-Type": "application/json",
+ "User-Agent": "Autopilot",
+ "X-Plane-Delivery": str(uuid.uuid4()),
+ "X-Plane-Event": event,
+ }
+
+ # # Your secret key
+ event_data = (
+ json.loads(json.dumps(event_data, cls=DjangoJSONEncoder))
+ if event_data is not None
+ else None
+ )
+
+ activity = (
+ json.loads(json.dumps(activity, cls=DjangoJSONEncoder))
+ if activity is not None
+ else None
+ )
+
+ action = {
+ "POST": "create",
+ "PATCH": "update",
+ "PUT": "update",
+ "DELETE": "delete",
+ }.get(action, action)
+
+ payload = {
+ "event": event,
+ "action": action,
+ "webhook_id": str(webhook.id),
+ "workspace_id": str(webhook.workspace_id),
+ "data": event_data,
+ "activity": activity,
+ }
+
+ # Use HMAC for generating signature
+ if webhook.secret_key:
+ hmac_signature = hmac.new(
+ webhook.secret_key.encode("utf-8"),
+ json.dumps(payload).encode("utf-8"),
+ hashlib.sha256,
+ )
+ signature = hmac_signature.hexdigest()
+ headers["X-Plane-Signature"] = signature
+
+ # Send the webhook event
+ response = requests.post(
+ webhook.url,
+ headers=headers,
+ json=payload,
+ timeout=30,
+ )
+
+ # Log the webhook request
+ WebhookLog.objects.create(
+ workspace_id=str(webhook.workspace_id),
+ webhook_id=str(webhook.id),
+ event_type=str(event),
+ request_method=str(action),
+ request_headers=str(headers),
+ request_body=str(payload),
+ response_status=str(response.status_code),
+ response_headers=str(response.headers),
+ response_body=str(response.text),
+ retry_count=str(self.request.retries),
+ )
+
+ except requests.RequestException as e:
+ # Log the failed webhook request
+ WebhookLog.objects.create(
+ workspace_id=str(webhook.workspace_id),
+ webhook_id=str(webhook.id),
+ event_type=str(event),
+ request_method=str(action),
+ request_headers=str(headers),
+ request_body=str(payload),
+ response_status=500,
+ response_headers="",
+ response_body=str(e),
+ retry_count=str(self.request.retries),
+ )
+ # Retry logic
+ if self.request.retries >= self.max_retries:
+ Webhook.objects.filter(pk=webhook.id).update(is_active=False)
+ if webhook:
+ # send email for the deactivation of the webhook
+ send_webhook_deactivation_email(
+ webhook_id=webhook.id,
+ receiver_id=webhook.created_by_id,
+ reason=str(e),
+ current_site=current_site,
+ )
+ return
+ raise requests.RequestException()
+
+ except Exception as e:
+ if settings.DEBUG:
+ print(e)
+ log_exception(e)
+ return
+
+
+@shared_task
+def webhook_activity(
+ event,
+ verb,
+ field,
+ old_value,
+ new_value,
+ actor_id,
+ slug,
+ current_site,
+ event_id,
+ old_identifier,
+ new_identifier,
+):
+ try:
+ webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True)
+
+ if event == "project":
+ webhooks = webhooks.filter(project=True)
+
+ if event == "issue":
+ webhooks = webhooks.filter(issue=True)
+
+ if event == "module" or event == "module_issue":
+ webhooks = webhooks.filter(module=True)
+
+ if event == "cycle" or event == "cycle_issue":
+ webhooks = webhooks.filter(cycle=True)
+
+ if event == "issue_comment":
+ webhooks = webhooks.filter(issue_comment=True)
+
+ for webhook in webhooks:
+ webhook_send_task.delay(
+ webhook=webhook.id,
+ slug=slug,
+ event=event,
+ event_data=get_model_data(
+ event=event,
+ event_id=event_id,
+ ),
+ action=verb,
+ current_site=current_site,
+ activity={
+ "field": field,
+ "new_value": new_value,
+ "old_value": old_value,
+ "actor": get_model_data(event="user", event_id=actor_id),
+ "old_identifier": old_identifier,
+ "new_identifier": new_identifier,
+ },
+ )
+ return
+ except Exception as e:
+ # Return if a does not exist error occurs
+ if isinstance(e, ObjectDoesNotExist):
+ return
+ if settings.DEBUG:
+ print(e)
+ log_exception(e)
+ return
+
+
+@shared_task
+def model_activity(
+ model_name,
+ model_id,
+ requested_data,
+ current_instance,
+ actor_id,
+ slug,
+ origin=None,
+):
+ """Function takes in two json and computes differences between keys of both the json"""
+ if current_instance is None:
+ webhook_activity.delay(
+ event=model_name,
+ verb="created",
+ field=None,
+ old_value=None,
+ new_value=None,
+ actor_id=actor_id,
+ slug=slug,
+ current_site=origin,
+ event_id=model_id,
+ old_identifier=None,
+ new_identifier=None,
+ )
+ return
+
+ # Load the current instance
+ current_instance = (
+ json.loads(current_instance) if current_instance is not None else None
+ )
+
+ # Loop through all keys in requested data and check the current value and requested value
+ for key in requested_data:
+ # Check if key is present in current instance or not
+ if key in current_instance:
+ current_value = current_instance.get(key, None)
+ requested_value = requested_data.get(key, None)
+ if current_value != requested_value:
+ webhook_activity.delay(
+ event=model_name,
+ verb="updated",
+ field=key,
+ old_value=current_value,
+ new_value=requested_value,
+ actor_id=actor_id,
+ slug=slug,
+ current_site=origin,
+ event_id=model_id,
+ old_identifier=None,
+ new_identifier=None,
+ )
+
+ return
diff --git a/apiserver/plane/celery.py b/apiserver/plane/celery.py
index 056dfb16b..d3e742f14 100644
--- a/apiserver/plane/celery.py
+++ b/apiserver/plane/celery.py
@@ -32,6 +32,10 @@ app.conf.beat_schedule = {
"task": "plane.bgtasks.email_notification_task.stack_email_notification",
"schedule": crontab(minute="*/5"),
},
+ "check-every-day-to-delete-api-logs": {
+ "task": "plane.bgtasks.api_logs_task.delete_api_logs",
+ "schedule": crontab(hour=0, minute=0),
+ },
}
# Load task modules from all registered Django app configs.
diff --git a/apiserver/plane/db/management/commands/test_email.py b/apiserver/plane/db/management/commands/test_email.py
index 63b602518..facea7e9c 100644
--- a/apiserver/plane/db/management/commands/test_email.py
+++ b/apiserver/plane/db/management/commands/test_email.py
@@ -1,6 +1,9 @@
from django.core.mail import EmailMultiAlternatives, get_connection
from django.core.management import BaseCommand, CommandError
+from django.template.loader import render_to_string
+from django.utils.html import strip_tags
+# Module imports
from plane.license.utils.instance_value import get_email_configuration
@@ -37,10 +40,10 @@ class Command(BaseCommand):
timeout=30,
)
# Prepare email details
- subject = "Email Notification from Plane"
- message = (
- "This is a sample email notification sent from Plane application."
- )
+ subject = "Test email from Plane"
+
+ html_content = render_to_string("emails/test_email.html")
+ text_content = strip_tags(html_content)
self.stdout.write(self.style.SUCCESS("Trying to send test email..."))
@@ -48,11 +51,14 @@ class Command(BaseCommand):
try:
msg = EmailMultiAlternatives(
subject=subject,
- body=message,
+ body=text_content,
from_email=EMAIL_FROM,
- to=[receiver_email],
+ to=[
+ receiver_email,
+ ],
connection=connection,
)
+ msg.attach_alternative(html_content, "text/html")
msg.send()
self.stdout.write(self.style.SUCCESS("Email successfully sent"))
except Exception as e:
diff --git a/apiserver/plane/db/migrations/0065_auto_20240415_0937.py b/apiserver/plane/db/migrations/0065_auto_20240415_0937.py
index 9d8cc50be..4698c7120 100644
--- a/apiserver/plane/db/migrations/0065_auto_20240415_0937.py
+++ b/apiserver/plane/db/migrations/0065_auto_20240415_0937.py
@@ -45,6 +45,51 @@ def migrate_user_profile(apps, schema_editor):
)
+def user_favorite_migration(apps, schema_editor):
+ # Import the models
+ CycleFavorite = apps.get_model("db", "CycleFavorite")
+ ModuleFavorite = apps.get_model("db", "ModuleFavorite")
+ ProjectFavorite = apps.get_model("db", "ProjectFavorite")
+ PageFavorite = apps.get_model("db", "PageFavorite")
+ IssueViewFavorite = apps.get_model("db", "IssueViewFavorite")
+ UserFavorite = apps.get_model("db", "UserFavorite")
+
+ # List of source models
+ source_models = [
+ CycleFavorite,
+ ModuleFavorite,
+ ProjectFavorite,
+ PageFavorite,
+ IssueViewFavorite,
+ ]
+
+ entity_mapper = {
+ "CycleFavorite": "cycle",
+ "ModuleFavorite": "module",
+ "ProjectFavorite": "project",
+ "PageFavorite": "page",
+ "IssueViewFavorite": "view",
+ }
+
+ for source_model in source_models:
+ entity_type = entity_mapper[source_model.__name__]
+ UserFavorite.objects.bulk_create(
+ [
+ UserFavorite(
+ user_id=obj.user_id,
+ entity_type=entity_type,
+ entity_identifier=str(getattr(obj, entity_type).id),
+ project_id=obj.project_id,
+ workspace_id=obj.workspace_id,
+ created_by_id=obj.created_by_id,
+ updated_by_id=obj.updated_by_id,
+ )
+ for obj in source_model.objects.all().iterator()
+ ],
+ batch_size=1000,
+ )
+
+
class Migration(migrations.Migration):
dependencies = [
@@ -257,4 +302,161 @@ class Migration(migrations.Migration):
model_name="user",
name="use_case",
),
+ migrations.AddField(
+ model_name="globalview",
+ name="logo_props",
+ field=models.JSONField(default=dict),
+ ),
+ # Pages
+ migrations.AddField(
+ model_name="page",
+ name="logo_props",
+ field=models.JSONField(default=dict),
+ ),
+ migrations.AddField(
+ model_name="page",
+ name="description_binary",
+ field=models.BinaryField(null=True),
+ ),
+ migrations.AlterField(
+ model_name="page",
+ name="name",
+ field=models.CharField(blank=True, max_length=255),
+ ),
+ # Estimates
+ migrations.AddField(
+ model_name="estimate",
+ name="type",
+ field=models.CharField(default="Categories", max_length=255),
+ ),
+ migrations.AlterField(
+ model_name="estimatepoint",
+ name="key",
+ field=models.IntegerField(
+ default=0,
+ validators=[
+ django.core.validators.MinValueValidator(0),
+ django.core.validators.MaxValueValidator(12),
+ ],
+ ),
+ ),
+ migrations.AlterField(
+ model_name="issue",
+ name="estimate_point",
+ field=models.IntegerField(
+ blank=True,
+ null=True,
+ validators=[
+ django.core.validators.MinValueValidator(0),
+ django.core.validators.MaxValueValidator(12),
+ ],
+ ),
+ ),
+ # workspace user properties
+ migrations.AlterModelTable(
+ name="workspaceuserproperties",
+ table="workspace_user_properties",
+ ),
+ # Favorites
+ migrations.CreateModel(
+ name="UserFavorite",
+ fields=[
+ (
+ "created_at",
+ models.DateTimeField(
+ auto_now_add=True, verbose_name="Created At"
+ ),
+ ),
+ (
+ "updated_at",
+ models.DateTimeField(
+ auto_now=True, verbose_name="Last Modified At"
+ ),
+ ),
+ (
+ "id",
+ models.UUIDField(
+ db_index=True,
+ default=uuid.uuid4,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ unique=True,
+ ),
+ ),
+ ("entity_type", models.CharField(max_length=100)),
+ ("entity_identifier", models.UUIDField(blank=True, null=True)),
+ (
+ "name",
+ models.CharField(blank=True, max_length=255, null=True),
+ ),
+ ("is_folder", models.BooleanField(default=False)),
+ ("sequence", models.IntegerField(default=65535)),
+ (
+ "created_by",
+ models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="%(class)s_created_by",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Created By",
+ ),
+ ),
+ (
+ "parent",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="parent_folder",
+ to="db.userfavorite",
+ ),
+ ),
+ (
+ "project",
+ models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="project_%(class)s",
+ to="db.project",
+ ),
+ ),
+ (
+ "updated_by",
+ models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="%(class)s_updated_by",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Last Modified By",
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="favorites",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "workspace",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="workspace_%(class)s",
+ to="db.workspace",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "User Favorite",
+ "verbose_name_plural": "User Favorites",
+ "db_table": "user_favorites",
+ "ordering": ("-created_at",),
+ "unique_together": {
+ ("entity_type", "user", "entity_identifier")
+ },
+ },
+ ),
+ migrations.RunPython(user_favorite_migration),
]
diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py
index 2dc6d7909..b11ce7aa3 100644
--- a/apiserver/plane/db/models/__init__.py
+++ b/apiserver/plane/db/models/__init__.py
@@ -98,3 +98,5 @@ from .exporter import ExporterHistory
from .webhook import Webhook, WebhookLog
from .dashboard import Dashboard, DashboardWidget, Widget
+
+from .favorite import UserFavorite
diff --git a/apiserver/plane/db/models/estimate.py b/apiserver/plane/db/models/estimate.py
index 5a783f9b9..6ff1186c3 100644
--- a/apiserver/plane/db/models/estimate.py
+++ b/apiserver/plane/db/models/estimate.py
@@ -11,6 +11,7 @@ class Estimate(ProjectBaseModel):
description = models.TextField(
verbose_name="Estimate Description", blank=True
)
+ type = models.CharField(max_length=255, default="Categories")
def __str__(self):
"""Return name of the estimate"""
@@ -31,7 +32,7 @@ class EstimatePoint(ProjectBaseModel):
related_name="points",
)
key = models.IntegerField(
- default=0, validators=[MinValueValidator(0), MaxValueValidator(7)]
+ default=0, validators=[MinValueValidator(0), MaxValueValidator(12)]
)
description = models.TextField(blank=True)
value = models.CharField(max_length=20)
diff --git a/apiserver/plane/db/models/favorite.py b/apiserver/plane/db/models/favorite.py
new file mode 100644
index 000000000..2ea1014bc
--- /dev/null
+++ b/apiserver/plane/db/models/favorite.py
@@ -0,0 +1,52 @@
+from django.conf import settings
+
+# Django imports
+from django.db import models
+
+# Module imports
+from .workspace import WorkspaceBaseModel
+
+
+class UserFavorite(WorkspaceBaseModel):
+ """_summary_
+ UserFavorite (model): To store all the favorites of the user
+ """
+
+ user = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.CASCADE,
+ related_name="favorites",
+ )
+ entity_type = models.CharField(max_length=100)
+ entity_identifier = models.UUIDField(null=True, blank=True)
+ name = models.CharField(max_length=255, blank=True, null=True)
+ is_folder = models.BooleanField(default=False)
+ sequence = models.IntegerField(default=65535)
+ parent = models.ForeignKey(
+ "self",
+ on_delete=models.CASCADE,
+ null=True,
+ blank=True,
+ related_name="parent_folder",
+ )
+
+ class Meta:
+ unique_together = ["entity_type", "user", "entity_identifier"]
+ verbose_name = "User Favorite"
+ verbose_name_plural = "User Favorites"
+ db_table = "user_favorites"
+ ordering = ("-created_at",)
+
+ def save(self, *args, **kwargs):
+ if self._state.adding:
+ largest_sequence = UserFavorite.objects.filter(
+ workspace=self.project.workspace
+ ).aggregate(largest=models.Max("sequence"))["largest"]
+ if largest_sequence is not None:
+ self.sequence = largest_sequence + 10000
+
+ super(UserFavorite, self).save(*args, **kwargs)
+
+ def __str__(self):
+ """Return user and the entity type"""
+ return f"{self.user.email} <{self.entity_type}>"
diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py
index e3d1e62a7..7a17853c3 100644
--- a/apiserver/plane/db/models/issue.py
+++ b/apiserver/plane/db/models/issue.py
@@ -120,7 +120,7 @@ class Issue(ProjectBaseModel):
related_name="state_issue",
)
estimate_point = models.IntegerField(
- validators=[MinValueValidator(0), MaxValueValidator(7)],
+ validators=[MinValueValidator(0), MaxValueValidator(12)],
null=True,
blank=True,
)
diff --git a/apiserver/plane/db/models/page.py b/apiserver/plane/db/models/page.py
index edebaf132..3602bce1f 100644
--- a/apiserver/plane/db/models/page.py
+++ b/apiserver/plane/db/models/page.py
@@ -16,7 +16,7 @@ def get_view_props():
class Page(ProjectBaseModel):
- name = models.CharField(max_length=255)
+ name = models.CharField(max_length=255, blank=True)
description = models.JSONField(default=dict, blank=True)
description_html = models.TextField(blank=True, default="")
description_stripped = models.TextField(blank=True, null=True)
@@ -42,6 +42,8 @@ class Page(ProjectBaseModel):
archived_at = models.DateField(null=True)
is_locked = models.BooleanField(default=False)
view_props = models.JSONField(default=get_view_props)
+ logo_props = models.JSONField(default=dict)
+ description_binary = models.BinaryField(null=True)
class Meta:
verbose_name = "Page"
diff --git a/apiserver/plane/db/models/view.py b/apiserver/plane/db/models/view.py
index d74eb6ca2..87f0899c3 100644
--- a/apiserver/plane/db/models/view.py
+++ b/apiserver/plane/db/models/view.py
@@ -64,6 +64,7 @@ class GlobalView(BaseModel):
)
query_data = models.JSONField(default=dict)
sort_order = models.FloatField(default=65535)
+ logo_props = models.JSONField(default=dict)
class Meta:
verbose_name = "Global View"
@@ -86,6 +87,7 @@ class GlobalView(BaseModel):
return f"{self.name} <{self.workspace.name}>"
+# DEPRECATED TODO: - Remove in next release
class IssueView(WorkspaceBaseModel):
name = models.CharField(max_length=255, verbose_name="View Name")
description = models.TextField(verbose_name="View Description", blank=True)
diff --git a/apiserver/plane/db/models/workspace.py b/apiserver/plane/db/models/workspace.py
index 56e136126..fe39f2d09 100644
--- a/apiserver/plane/db/models/workspace.py
+++ b/apiserver/plane/db/models/workspace.py
@@ -325,7 +325,7 @@ class WorkspaceUserProperties(BaseModel):
unique_together = ["workspace", "user"]
verbose_name = "Workspace User Property"
verbose_name_plural = "Workspace User Property"
- db_table = "Workspace_user_properties"
+ db_table = "workspace_user_properties"
ordering = ("-created_at",)
def __str__(self):
diff --git a/apiserver/plane/license/api/serializers/instance.py b/apiserver/plane/license/api/serializers/instance.py
index 31209ac19..730d388f4 100644
--- a/apiserver/plane/license/api/serializers/instance.py
+++ b/apiserver/plane/license/api/serializers/instance.py
@@ -8,13 +8,14 @@ class InstanceSerializer(BaseSerializer):
class Meta:
model = Instance
- fields = "__all__"
- read_only_fields = [
- "id",
- "instance_id",
+ exclude = [
"license_key",
"api_key",
"version",
+ ]
+ read_only_fields = [
+ "id",
+ "instance_id",
"email",
"last_checked_at",
"is_setup_done",
diff --git a/apiserver/plane/license/api/views/__init__.py b/apiserver/plane/license/api/views/__init__.py
index cddaff0eb..b10702b8a 100644
--- a/apiserver/plane/license/api/views/__init__.py
+++ b/apiserver/plane/license/api/views/__init__.py
@@ -16,4 +16,5 @@ from .admin import (
InstanceAdminSignUpEndpoint,
InstanceAdminUserMeEndpoint,
InstanceAdminSignOutEndpoint,
+ InstanceAdminUserSessionEndpoint,
)
diff --git a/apiserver/plane/license/api/views/admin.py b/apiserver/plane/license/api/views/admin.py
index c9c028f32..5d93aba49 100644
--- a/apiserver/plane/license/api/views/admin.py
+++ b/apiserver/plane/license/api/views/admin.py
@@ -28,7 +28,11 @@ from plane.license.models import Instance, InstanceAdmin
from plane.db.models import User, Profile
from plane.utils.cache import cache_response, invalidate_cache
from plane.authentication.utils.login import user_login
-from plane.authentication.utils.host import base_host
+from plane.authentication.utils.host import base_host, user_ip
+from plane.authentication.adapter.error import (
+ AUTHENTICATION_ERROR_CODES,
+ AuthenticationException,
+)
class InstanceAdminEndpoint(BaseAPIView):
@@ -95,29 +99,27 @@ class InstanceAdminSignUpEndpoint(View):
# Check instance first
instance = Instance.objects.first()
if instance is None:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INSTANCE_NOT_CONFIGURED"
+ ],
+ error_message="INSTANCE_NOT_CONFIGURED",
+ )
url = urljoin(
- base_host(request=request),
- "god-mode/setup?"
- + urlencode(
- {
- "error_code": "INSTANCE_NOT_CONFIGURED",
- "error_message": "Instance is not configured",
- }
- ),
+ base_host(request=request, is_admin=True),
+ "?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
# check if the instance has already an admin registered
if InstanceAdmin.objects.first():
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["ADMIN_ALREADY_EXIST"],
+ error_message="ADMIN_ALREADY_EXIST",
+ )
url = urljoin(
- base_host(request=request),
- "god-mode/setup?"
- + urlencode(
- {
- "error_code": "ADMIN_ALREADY_EXIST",
- "error_message": "Admin for the instance has been already registered.",
- }
- ),
+ base_host(request=request, is_admin=True),
+ "?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
@@ -131,20 +133,22 @@ class InstanceAdminSignUpEndpoint(View):
# return error if the email and password is not present
if not email or not password or not first_name:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME"
+ ],
+ error_message="REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME",
+ payload={
+ "email": email,
+ "first_name": first_name,
+ "last_name": last_name,
+ "company_name": company_name,
+ "is_telemetry_enabled": is_telemetry_enabled,
+ },
+ )
url = urljoin(
- base_host(request=request),
- "god-mode/setup?"
- + urlencode(
- {
- "email": email,
- "first_name": first_name,
- "last_name": last_name,
- "company_name": company_name,
- "is_telemetry_enabled": is_telemetry_enabled,
- "error_code": "REQUIRED_EMAIL_PASSWORD_FIRST_NAME",
- "error_message": "Email, name and password are required",
- }
- ),
+ base_host(request=request, is_admin=True),
+ "?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
@@ -153,60 +157,64 @@ class InstanceAdminSignUpEndpoint(View):
try:
validate_email(email)
except ValidationError:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_ADMIN_EMAIL"],
+ error_message="INVALID_ADMIN_EMAIL",
+ payload={
+ "email": email,
+ "first_name": first_name,
+ "last_name": last_name,
+ "company_name": company_name,
+ "is_telemetry_enabled": is_telemetry_enabled,
+ },
+ )
url = urljoin(
- base_host(request=request),
- "god-mode/setup?"
- + urlencode(
- {
- "email": email,
- "first_name": first_name,
- "last_name": last_name,
- "company_name": company_name,
- "is_telemetry_enabled": is_telemetry_enabled,
- "error_code": "INVALID_EMAIL",
- "error_message": "Please provide a valid email address.",
- }
- ),
+ base_host(request=request, is_admin=True),
+ "?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
# Check if already a user exists or not
# Existing user
if User.objects.filter(email=email).exists():
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "ADMIN_USER_ALREADY_EXIST"
+ ],
+ error_message="ADMIN_USER_ALREADY_EXIST",
+ payload={
+ "email": email,
+ "first_name": first_name,
+ "last_name": last_name,
+ "company_name": company_name,
+ "is_telemetry_enabled": is_telemetry_enabled,
+ },
+ )
url = urljoin(
- base_host(request=request),
- "god-mode/setup?"
- + urlencode(
- {
- "email": email,
- "first_name": first_name,
- "last_name": last_name,
- "company_name": company_name,
- "is_telemetry_enabled": is_telemetry_enabled,
- "error_code": "USER_ALREADY_EXISTS",
- "error_message": "User already exists.",
- }
- ),
+ base_host(request=request, is_admin=True),
+ "?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
else:
results = zxcvbn(password)
if results["score"] < 3:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INVALID_ADMIN_PASSWORD"
+ ],
+ error_message="INVALID_ADMIN_PASSWORD",
+ payload={
+ "email": email,
+ "first_name": first_name,
+ "last_name": last_name,
+ "company_name": company_name,
+ "is_telemetry_enabled": is_telemetry_enabled,
+ },
+ )
url = urljoin(
- base_host(request=request),
- "god-mode/setup?"
- + urlencode(
- {
- "email": email,
- "first_name": first_name,
- "last_name": last_name,
- "company_name": company_name,
- "is_telemetry_enabled": is_telemetry_enabled,
- "error_code": "INVALID_PASSWORD",
- "error_message": "Invalid password provided.",
- }
- ),
+ base_host(request=request, is_admin=True),
+ "?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
@@ -235,12 +243,13 @@ class InstanceAdminSignUpEndpoint(View):
)
# Make the setup flag True
instance.is_setup_done = True
+ instance.instance_name = company_name
instance.is_telemetry_enabled = is_telemetry_enabled
instance.save()
# get tokens for user
- user_login(request=request, user=user)
- url = urljoin(base_host(request=request), "god-mode/general")
+ user_login(request=request, user=user, is_admin=True)
+ url = urljoin(base_host(request=request, is_admin=True), "general")
return HttpResponseRedirect(url)
@@ -254,15 +263,15 @@ class InstanceAdminSignInEndpoint(View):
# Check instance first
instance = Instance.objects.first()
if instance is None:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INSTANCE_NOT_CONFIGURED"
+ ],
+ error_message="INSTANCE_NOT_CONFIGURED",
+ )
url = urljoin(
- base_host(request=request),
- "god-mode/login?"
- + urlencode(
- {
- "error_code": "INSTANCE_NOT_CONFIGURED",
- "error_message": "Instance is not configured",
- }
- ),
+ base_host(request=request, is_admin=True),
+ "?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
@@ -272,16 +281,18 @@ class InstanceAdminSignInEndpoint(View):
# return error if the email and password is not present
if not email or not password:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "REQUIRED_ADMIN_EMAIL_PASSWORD"
+ ],
+ error_message="REQUIRED_ADMIN_EMAIL_PASSWORD",
+ payload={
+ "email": email,
+ },
+ )
url = urljoin(
- base_host(request=request),
- "god-mode/login?"
- + urlencode(
- {
- "email": email,
- "error_code": "REQUIRED_EMAIL_PASSWORD",
- "error_message": "Email and password are required",
- }
- ),
+ base_host(request=request, is_admin=True),
+ "?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
@@ -290,64 +301,84 @@ class InstanceAdminSignInEndpoint(View):
try:
validate_email(email)
except ValidationError:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_ADMIN_EMAIL"],
+ error_message="INVALID_ADMIN_EMAIL",
+ payload={
+ "email": email,
+ },
+ )
url = urljoin(
- base_host(request=request),
- "god-mode/login?"
- + urlencode(
- {
- "email": email,
- "error_code": "INVALID_EMAIL",
- "error_message": "Please provide a valid email address.",
- }
- ),
+ base_host(request=request, is_admin=True),
+ "?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
# Fetch the user
user = User.objects.filter(email=email).first()
+ # is_active
+ if not user.is_active:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "ADMIN_USER_DEACTIVATED"
+ ],
+ error_message="ADMIN_USER_DEACTIVATED",
+ )
+ url = urljoin(
+ base_host(request=request, is_admin=True),
+ "?" + urlencode(exc.get_error_dict()),
+ )
+ return HttpResponseRedirect(url)
+
# Error out if the user is not present
if not user:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "ADMIN_USER_DOES_NOT_EXIST"
+ ],
+ error_message="ADMIN_USER_DOES_NOT_EXIST",
+ payload={
+ "email": email,
+ },
+ )
url = urljoin(
- base_host(request=request),
- "god-mode/login?"
- + urlencode(
- {
- "email": email,
- "error_code": "USER_DOES_NOT_EXIST",
- "error_message": "User does not exist",
- }
- ),
+ base_host(request=request, is_admin=True),
+ "?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
# Check password of the user
if not user.check_password(password):
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "ADMIN_AUTHENTICATION_FAILED"
+ ],
+ error_message="ADMIN_AUTHENTICATION_FAILED",
+ payload={
+ "email": email,
+ },
+ )
url = urljoin(
- base_host(request=request),
- "god-mode/login?"
- + urlencode(
- {
- "email": email,
- "error_code": "AUTHENTICATION_FAILED",
- "error_message": "Sorry, we could not find an admin user with the provided credentials. Please try again.",
- }
- ),
+ base_host(request=request, is_admin=True),
+ "?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
# Check if the user is an instance admin
if not InstanceAdmin.objects.filter(instance=instance, user=user):
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "ADMIN_AUTHENTICATION_FAILED"
+ ],
+ error_message="ADMIN_AUTHENTICATION_FAILED",
+ payload={
+ "email": email,
+ },
+ )
url = urljoin(
- base_host(request=request),
- "god-mode/login?"
- + urlencode(
- {
- "email": email,
- "error_code": "AUTHENTICATION_FAILED",
- "error_message": "Sorry, we could not find an admin user with the provided credentials. Please try again.",
- }
- ),
+ base_host(request=request, is_admin=True),
+ "?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
# settings last active for the user
@@ -360,8 +391,8 @@ class InstanceAdminSignInEndpoint(View):
user.save()
# get tokens for user
- user_login(request=request, user=user)
- url = urljoin(base_host(request=request), "god-mode/general")
+ user_login(request=request, user=user, is_admin=True)
+ url = urljoin(base_host(request=request, is_admin=True), "general")
return HttpResponseRedirect(url)
@@ -379,6 +410,30 @@ class InstanceAdminUserMeEndpoint(BaseAPIView):
)
+class InstanceAdminUserSessionEndpoint(BaseAPIView):
+
+ permission_classes = [
+ AllowAny,
+ ]
+
+ def get(self, request):
+ if (
+ request.user.is_authenticated
+ and InstanceAdmin.objects.filter(user=request.user).exists()
+ ):
+ serializer = InstanceAdminMeSerializer(request.user)
+ data = {"is_authenticated": True}
+ data["user"] = serializer.data
+ return Response(
+ data,
+ status=status.HTTP_200_OK,
+ )
+ else:
+ return Response(
+ {"is_authenticated": False}, status=status.HTTP_200_OK
+ )
+
+
class InstanceAdminSignOutEndpoint(View):
permission_classes = [
@@ -386,9 +441,17 @@ class InstanceAdminSignOutEndpoint(View):
]
def post(self, request):
- logout(request)
- url = urljoin(
- base_host(request=request),
- "god-mode/login?" + urlencode({"success": "true"}),
- )
- return HttpResponseRedirect(url)
+ # Get user
+ try:
+ user = User.objects.get(pk=request.user.id)
+ user.last_logout_ip = user_ip(request=request)
+ user.last_logout_time = timezone.now()
+ user.save()
+ # Log the user out
+ logout(request)
+ url = urljoin(base_host(request=request, is_admin=True))
+ return HttpResponseRedirect(url)
+ except Exception:
+ return HttpResponseRedirect(
+ base_host(request=request, is_admin=True)
+ )
diff --git a/apiserver/plane/license/api/views/instance.py b/apiserver/plane/license/api/views/instance.py
index 40b3c7e0d..525ab54ec 100644
--- a/apiserver/plane/license/api/views/instance.py
+++ b/apiserver/plane/license/api/views/instance.py
@@ -2,6 +2,7 @@
import os
# Django imports
+from django.conf import settings
# Third party imports
from rest_framework import status
@@ -37,6 +38,7 @@ class InstanceEndpoint(BaseAPIView):
@cache_response(60 * 60 * 2, user=False)
def get(self, request):
instance = Instance.objects.first()
+
# get the instance
if instance is None:
return Response(
@@ -53,8 +55,6 @@ class InstanceEndpoint(BaseAPIView):
IS_GITHUB_ENABLED,
GITHUB_APP_NAME,
EMAIL_HOST,
- EMAIL_HOST_USER,
- EMAIL_HOST_PASSWORD,
ENABLE_MAGIC_LINK_LOGIN,
ENABLE_EMAIL_PASSWORD,
SLACK_CLIENT_ID,
@@ -80,14 +80,6 @@ class InstanceEndpoint(BaseAPIView):
"key": "EMAIL_HOST",
"default": os.environ.get("EMAIL_HOST", ""),
},
- {
- "key": "EMAIL_HOST_USER",
- "default": os.environ.get("EMAIL_HOST_USER", ""),
- },
- {
- "key": "EMAIL_HOST_PASSWORD",
- "default": os.environ.get("EMAIL_HOST_PASSWORD", ""),
- },
{
"key": "ENABLE_MAGIC_LINK_LOGIN",
"default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "1"),
@@ -148,11 +140,13 @@ class InstanceEndpoint(BaseAPIView):
)
# is smtp configured
- data["is_smtp_configured"] = (
- bool(EMAIL_HOST)
- and bool(EMAIL_HOST_USER)
- and bool(EMAIL_HOST_PASSWORD)
- )
+ data["is_smtp_configured"] = bool(EMAIL_HOST)
+
+ # Base URL
+ data["admin_base_url"] = settings.ADMIN_BASE_URL
+ data["space_base_url"] = settings.SPACE_BASE_URL
+ data["app_base_url"] = settings.APP_BASE_URL
+
instance_data = serializer.data
instance_data["workspaces_exist"] = Workspace.objects.count() > 1
diff --git a/apiserver/plane/license/management/commands/register_instance.py b/apiserver/plane/license/management/commands/register_instance.py
index 32a37879f..b5cc8a60d 100644
--- a/apiserver/plane/license/management/commands/register_instance.py
+++ b/apiserver/plane/license/management/commands/register_instance.py
@@ -46,7 +46,7 @@ class Command(BaseCommand):
}
instance = Instance.objects.create(
- instance_name="Plane Free",
+ instance_name="Plane Community Edition",
instance_id=secrets.token_hex(12),
license_key=None,
api_key=secrets.token_hex(8),
diff --git a/apiserver/plane/license/urls.py b/apiserver/plane/license/urls.py
index b95ae74d6..b4f19e52c 100644
--- a/apiserver/plane/license/urls.py
+++ b/apiserver/plane/license/urls.py
@@ -10,6 +10,7 @@ from plane.license.api.views import (
SignUpScreenVisitedEndpoint,
InstanceAdminUserMeEndpoint,
InstanceAdminSignOutEndpoint,
+ InstanceAdminUserSessionEndpoint,
)
urlpatterns = [
@@ -28,6 +29,11 @@ urlpatterns = [
InstanceAdminUserMeEndpoint.as_view(),
name="instance-admins",
),
+ path(
+ "admins/session/",
+ InstanceAdminUserSessionEndpoint.as_view(),
+ name="instance-admin-session",
+ ),
path(
"admins/sign-out/",
InstanceAdminSignOutEndpoint.as_view(),
diff --git a/apiserver/plane/license/utils/encryption.py b/apiserver/plane/license/utils/encryption.py
index 11bd9000e..6781605dd 100644
--- a/apiserver/plane/license/utils/encryption.py
+++ b/apiserver/plane/license/utils/encryption.py
@@ -3,6 +3,8 @@ import hashlib
from django.conf import settings
from cryptography.fernet import Fernet
+from plane.utils.exception_logger import log_exception
+
def derive_key(secret_key):
# Use a key derivation function to get a suitable encryption key
@@ -12,21 +14,29 @@ def derive_key(secret_key):
# Encrypt data
def encrypt_data(data):
- if data:
- cipher_suite = Fernet(derive_key(settings.SECRET_KEY))
- encrypted_data = cipher_suite.encrypt(data.encode())
- return encrypted_data.decode() # Convert bytes to string
- else:
+ try:
+ if data:
+ cipher_suite = Fernet(derive_key(settings.SECRET_KEY))
+ encrypted_data = cipher_suite.encrypt(data.encode())
+ return encrypted_data.decode() # Convert bytes to string
+ else:
+ return ""
+ except Exception as e:
+ log_exception(e)
return ""
# Decrypt data
def decrypt_data(encrypted_data):
- if encrypted_data:
- cipher_suite = Fernet(derive_key(settings.SECRET_KEY))
- decrypted_data = cipher_suite.decrypt(
- encrypted_data.encode()
- ) # Convert string back to bytes
- return decrypted_data.decode()
- else:
+ try:
+ if encrypted_data:
+ cipher_suite = Fernet(derive_key(settings.SECRET_KEY))
+ decrypted_data = cipher_suite.decrypt(
+ encrypted_data.encode()
+ ) # Convert string back to bytes
+ return decrypted_data.decode()
+ else:
+ return ""
+ except Exception as e:
+ log_exception(e)
return ""
diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py
index 908ef446c..ed756642b 100644
--- a/apiserver/plane/settings/common.py
+++ b/apiserver/plane/settings/common.py
@@ -78,6 +78,7 @@ REST_FRAMEWORK = {
"DEFAULT_FILTER_BACKENDS": (
"django_filters.rest_framework.DjangoFilterBackend",
),
+ "EXCEPTION_HANDLER": "plane.authentication.adapter.exception.auth_exception_handler",
}
# Django Auth Backend
@@ -150,6 +151,7 @@ else:
"USER": os.environ.get("POSTGRES_USER"),
"PASSWORD": os.environ.get("POSTGRES_PASSWORD"),
"HOST": os.environ.get("POSTGRES_HOST"),
+ "PORT": os.environ.get("POSTGRES_PORT", "5432"),
}
}
@@ -265,6 +267,7 @@ CELERY_IMPORTS = (
"plane.bgtasks.exporter_expired_task",
"plane.bgtasks.file_asset_task",
"plane.bgtasks.email_notification_task",
+ "plane.bgtasks.api_logs_task",
# management tasks
"plane.bgtasks.dummy_data_task",
)
@@ -326,16 +329,24 @@ DATA_UPLOAD_MAX_MEMORY_SIZE = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
SESSION_COOKIE_SECURE = secure_origins
SESSION_COOKIE_HTTPONLY = True
SESSION_ENGINE = "plane.db.models.session"
-SESSION_COOKIE_AGE = 604800
+SESSION_COOKIE_AGE = os.environ.get("SESSION_COOKIE_AGE", 604800)
SESSION_COOKIE_NAME = "plane-session-id"
SESSION_COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", None)
-SESSION_SAVE_EVERY_REQUEST = True
+SESSION_SAVE_EVERY_REQUEST = (
+ os.environ.get("SESSION_SAVE_EVERY_REQUEST", "0") == "1"
+)
# Admin Cookie
ADMIN_SESSION_COOKIE_NAME = "plane-admin-session-id"
+ADMIN_SESSION_COOKIE_AGE = os.environ.get("ADMIN_SESSION_COOKIE_AGE", 3600)
# CSRF cookies
CSRF_COOKIE_SECURE = secure_origins
CSRF_COOKIE_HTTPONLY = True
CSRF_TRUSTED_ORIGINS = cors_allowed_origins
CSRF_COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", None)
+
+# Base URLs
+ADMIN_BASE_URL = os.environ.get("ADMIN_BASE_URL", None)
+SPACE_BASE_URL = os.environ.get("SPACE_BASE_URL", None)
+APP_BASE_URL = os.environ.get("APP_BASE_URL") or os.environ.get("WEB_URL")
diff --git a/apiserver/plane/settings/local.py b/apiserver/plane/settings/local.py
index 4f67e638b..b175e4c83 100644
--- a/apiserver/plane/settings/local.py
+++ b/apiserver/plane/settings/local.py
@@ -13,7 +13,9 @@ MIDDLEWARE += ("debug_toolbar.middleware.DebugToolbarMiddleware",) # noqa
DEBUG_TOOLBAR_PATCH_SETTINGS = False
# Only show emails in console don't send it to smtp
-EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
+EMAIL_BACKEND = os.environ.get(
+ "EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend"
+)
CACHES = {
"default": {
@@ -30,17 +32,6 @@ INTERNAL_IPS = ("127.0.0.1",)
MEDIA_URL = "/uploads/"
MEDIA_ROOT = os.path.join(BASE_DIR, "uploads") # noqa
-CORS_ALLOWED_ORIGINS = [
- "http://localhost:3000",
- "http://127.0.0.1:3000",
- "http://localhost:4000",
- "http://127.0.0.1:4000",
- "http://localhost:3333",
- "http://127.0.0.1:3333",
-]
-CSRF_TRUSTED_ORIGINS = CORS_ALLOWED_ORIGINS
-CORS_ALLOW_ALL_ORIGINS = True
-
LOG_DIR = os.path.join(BASE_DIR, "logs") # noqa
if not os.path.exists(LOG_DIR):
diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py
index c56222c67..806f83aca 100644
--- a/apiserver/plane/settings/production.py
+++ b/apiserver/plane/settings/production.py
@@ -12,8 +12,6 @@ SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
INSTALLED_APPS += ("scout_apm.django",) # noqa
-# Honor the 'X-Forwarded-Proto' header for request.is_secure()
-SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# Scout Settings
SCOUT_MONITOR = os.environ.get("SCOUT_MONITOR", False)
diff --git a/apiserver/plane/space/views/base.py b/apiserver/plane/space/views/base.py
index 023f27bbc..6b18a1546 100644
--- a/apiserver/plane/space/views/base.py
+++ b/apiserver/plane/space/views/base.py
@@ -21,6 +21,7 @@ from rest_framework.viewsets import ModelViewSet
# Module imports
from plane.utils.exception_logger import log_exception
from plane.utils.paginator import BasePaginator
+from plane.authentication.session import BaseSessionAuthentication
class TimezoneMixin:
@@ -49,6 +50,10 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
SearchFilter,
)
+ authentication_classes = [
+ BaseSessionAuthentication,
+ ]
+
filterset_fields = []
search_fields = []
@@ -146,6 +151,10 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
search_fields = []
+ authentication_classes = [
+ BaseSessionAuthentication,
+ ]
+
def filter_queryset(self, queryset):
for backend in list(self.filter_backends):
queryset = backend().filter_queryset(self.request, queryset, self)
diff --git a/apiserver/plane/utils/exception_logger.py b/apiserver/plane/utils/exception_logger.py
index f7bb50de2..0938f054b 100644
--- a/apiserver/plane/utils/exception_logger.py
+++ b/apiserver/plane/utils/exception_logger.py
@@ -6,6 +6,7 @@ from sentry_sdk import capture_exception
def log_exception(e):
+ print(e)
# Log the error
logger = logging.getLogger("plane")
logger.error(e)
diff --git a/apiserver/plane/utils/user_timezone_converter.py b/apiserver/plane/utils/user_timezone_converter.py
new file mode 100644
index 000000000..c946cfb27
--- /dev/null
+++ b/apiserver/plane/utils/user_timezone_converter.py
@@ -0,0 +1,25 @@
+import pytz
+
+def user_timezone_converter(queryset, datetime_fields, user_timezone):
+ # Create a timezone object for the user's timezone
+ user_tz = pytz.timezone(user_timezone)
+
+ # Check if queryset is a dictionary (single item) or a list of dictionaries
+ if isinstance(queryset, dict):
+ queryset_values = [queryset]
+ else:
+ queryset_values = list(queryset)
+
+ # Iterate over the dictionaries in the list
+ for item in queryset_values:
+ # Iterate over the datetime fields
+ for field in datetime_fields:
+ # Convert the datetime field to the user's timezone
+ if field in item and item[field]:
+ item[field] = item[field].astimezone(user_tz)
+
+ # If queryset was a single item, return a single item
+ if isinstance(queryset, dict):
+ return queryset_values[0]
+ else:
+ return queryset_values
diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt
index e33d580de..e7651ebfc 100644
--- a/apiserver/requirements/base.txt
+++ b/apiserver/requirements/base.txt
@@ -1,37 +1,63 @@
# base requirements
+# django
Django==4.2.11
-psycopg==3.1.12
-djangorestframework==3.14.0
-redis==4.6.0
-django-cors-headers==4.2.0
-whitenoise==6.5.0
-django-allauth==0.55.2
-faker==18.11.2
-django-filter==23.2
-jsonmodels==2.6.0
-djangorestframework-simplejwt==5.3.0
-sentry-sdk==1.30.0
-django-storages==1.14
-django-crum==0.7.9
-google-auth==2.22.0
-google-api-python-client==2.97.0
-django-redis==5.3.0
-uvicorn==0.23.2
-channels==4.0.0
-openai==1.2.4
-slack-sdk==3.21.3
-celery==5.3.4
-django_celery_beat==2.5.0
-psycopg-binary==3.1.12
-psycopg-c==3.1.12
-scout-apm==2.26.1
-openpyxl==3.1.2
-python-json-logger==2.0.7
-beautifulsoup4==4.12.2
+# rest framework
+djangorestframework==3.15.1
+# postgres
+psycopg==3.1.18
+psycopg-binary==3.1.18
+psycopg-c==3.1.18
dj-database-url==2.1.0
-posthog==3.0.2
-cryptography==42.0.4
-lxml==4.9.3
-boto3==1.28.40
+# redis
+redis==5.0.4
+django-redis==5.4.0
+# cors
+django-cors-headers==4.3.1
+# celery
+celery==5.4.0
+django_celery_beat==2.6.0
+# file serve
+whitenoise==6.6.0
+# fake data
+faker==25.0.0
+# filters
+django-filter==24.2
+# json model
+jsonmodels==2.7.0
+# sentry
+sentry-sdk==2.0.1
+# storage
+django-storages==1.14.2
+# user management
+django-crum==0.7.9
+# web server
+uvicorn==0.29.0
+# sockets
+channels==4.1.0
+# ai
+openai==1.25.0
+# slack
+slack-sdk==3.27.1
+# apm
+scout-apm==3.1.0
+# xlsx generation
+openpyxl==3.1.2
+# logging
+python-json-logger==2.0.7
+# html parser
+beautifulsoup4==4.12.3
+# analytics
+posthog==3.5.0
+# crypto
+cryptography==42.0.5
+# html validator
+lxml==5.2.1
+# s3
+boto3==1.34.96
+# password validator
zxcvbn==4.4.28
+# timezone
+pytz==2024.1
+# jwt
+PyJWT==2.8.0
diff --git a/apiserver/requirements/local.txt b/apiserver/requirements/local.txt
index 426236ed8..02792201b 100644
--- a/apiserver/requirements/local.txt
+++ b/apiserver/requirements/local.txt
@@ -1,3 +1,5 @@
-r base.txt
-
-django-debug-toolbar==4.1.0
\ No newline at end of file
+# debug toolbar
+django-debug-toolbar==4.3.0
+# formatter
+ruff==0.4.2
\ No newline at end of file
diff --git a/apiserver/requirements/production.txt b/apiserver/requirements/production.txt
index bea44fcfe..ed763c0df 100644
--- a/apiserver/requirements/production.txt
+++ b/apiserver/requirements/production.txt
@@ -1,3 +1,3 @@
-r base.txt
-
+# server
gunicorn==22.0.0
diff --git a/apiserver/requirements/test.txt b/apiserver/requirements/test.txt
index d3272191e..1ffc82d00 100644
--- a/apiserver/requirements/test.txt
+++ b/apiserver/requirements/test.txt
@@ -1,4 +1,4 @@
-r base.txt
-
+# test checker
pytest==7.1.2
coverage==6.5.0
\ No newline at end of file
diff --git a/apiserver/runtime.txt b/apiserver/runtime.txt
index cd0aac542..8cf46af5f 100644
--- a/apiserver/runtime.txt
+++ b/apiserver/runtime.txt
@@ -1 +1 @@
-python-3.11.9
\ No newline at end of file
+python-3.12.3
\ No newline at end of file
diff --git a/apiserver/templates/emails/auth/forgot_password.html b/apiserver/templates/emails/auth/forgot_password.html
index a58a8cef7..9df90724f 100644
--- a/apiserver/templates/emails/auth/forgot_password.html
+++ b/apiserver/templates/emails/auth/forgot_password.html
@@ -1,492 +1,47 @@
-
-
+
+
Set a new password to your Plane account
-
-
-
-
-
-
+
+
+
+
+
+
-
-
+
+
-
+
-
+
-
-
+
+
-
-
+
+
-
-
+
+
-
+
-
-
-
+
@@ -501,104 +56,23 @@
-
+
-
-
+
+
-
-
+
+
-
-
+
+
-
+
-
+
-
- Reset your Plane
- account's password
-
+
Reset your Plane account's password
@@ -607,47 +81,11 @@
-
+
-
+
-
- Someone, hopefully you, has
- requested a new password be
- set to your Plane account.
- If it was you, please click
- the button below to reset
- your password.
-
+
Someone, hopefully you, has requested a new password be set to your Plane account. If it was you, please click the button below to reset your password.
- Ignore this if you didn't
- ask for a new link.
-
+
Ignore this if you didn't ask for a new link.
@@ -784,114 +124,27 @@
-
+
-
+
-
-
+
+
-
-
+
+
-
+
-
-
+
+
-
+
-
-
-
+
@@ -912,254 +165,37 @@
-
+
-
-
+
+
-
-
+
+
-
-
+
+
-
+
-
-
-
-
-
-
-
-
-
+
+
+
-
-
-
-
+
+
-
- Despite our popularity, we
- are humbly early-stage. We
- are shipping fast, so please
- reach out to us with feature
- requests, major and minor
- nits, and anything else you
- find missing. We read every message, tweet, and conversation
- and update our public roadmap.
-
+
Despite our popularity, we are humbly early-stage. We are shipping fast, so please reach out to us with feature requests, major and minor nits, and anything else you find missing. We read every message, tweet, and conversation and update our public roadmap.