fix: auth redirection issues in the web, space and admin apps (#4414)

* fix: login redirection

* dev: log the user out when deactivating the account

* dev: update redirect uris for google and github

* fix: redirection url and invitation api and add redirection to god mode in nginx

* dev: add reset password redirection

* dev: update nginx headers

* dev: fix setup sh and env example and put validation for use minio when fetching project covers

* dev: stabilize dev setup

* fix: handled redirection error in web, space, and admin apps

* fix: resovled build errors

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
This commit is contained in:
guru_sainath 2024-05-09 17:46:31 +05:30 committed by GitHub
parent 692f570258
commit 58bf056ddb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 250 additions and 172 deletions

12
admin/Dockerfile.dev Normal file
View File

@ -0,0 +1,12 @@
FROM node:18-alpine
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
COPY . .
RUN yarn global add turbo
RUN yarn install
EXPOSE 3000
VOLUME [ "/app/node_modules", "/app/admin/node_modules" ]
CMD ["yarn", "dev", "--filter=admin"]

View File

@ -15,7 +15,7 @@ interface RootLayoutProps {
} }
const RootLayout = ({ children, ...pageProps }: RootLayoutProps) => { const RootLayout = ({ children, ...pageProps }: RootLayoutProps) => {
const prefix = parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0") === 0 ? "/" : "/god-mode/"; const prefix = "/god-mode/";
return ( return (
<html lang="en"> <html lang="en">

View File

@ -1,19 +0,0 @@
"use client";
import { ReactNode } from "react";
// lib
import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers";
// helpers
import { EAuthenticationPageType, EInstancePageType } from "@/helpers";
interface LoginLayoutProps {
children: ReactNode;
}
const LoginLayout = ({ children }: LoginLayoutProps) => (
<InstanceWrapper pageType={EInstancePageType.POST_SETUP}>
<AuthWrapper authType={EAuthenticationPageType.NOT_AUTHENTICATED}>{children}</AuthWrapper>
</InstanceWrapper>
);
export default LoginLayout;

View File

@ -1,18 +0,0 @@
"use client";
// layouts
import { DefaultLayout } from "@/layouts";
// components
import { PageHeader } from "@/components/core";
import { InstanceSignInForm } from "./components";
const LoginPage = () => (
<>
<PageHeader title="Setup - God Mode" />
<DefaultLayout>
<InstanceSignInForm />
</DefaultLayout>
</>
);
export default LoginPage;

View File

@ -1,20 +1,26 @@
"use client"; "use client";
import { useEffect } from "react"; // layouts
import { useRouter } from "next/navigation"; import { DefaultLayout } from "@/layouts";
// components // components
import { PageHeader } from "@/components/core"; import { PageHeader } from "@/components/core";
import { InstanceSignInForm } from "@/components/login";
// lib
import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers";
// helpers
import { EAuthenticationPageType, EInstancePageType } from "@/helpers";
const RootPage = () => { const LoginPage = () => (
const router = useRouter(); <>
<PageHeader title="Login - God Mode" />
<InstanceWrapper pageType={EInstancePageType.POST_SETUP}>
<AuthWrapper authType={EAuthenticationPageType.NOT_AUTHENTICATED}>
<DefaultLayout>
<InstanceSignInForm />
</DefaultLayout>
</AuthWrapper>
</InstanceWrapper>
</>
);
useEffect(() => router.push("/login"), [router]); export default LoginPage;
return (
<>
<PageHeader title="Plane - God Mode" />
</>
);
};
export default RootPage;

View File

@ -36,7 +36,7 @@ export const HelpSection: FC = () => {
// refs // refs
const helpOptionsRef = useRef<HTMLDivElement | null>(null); const helpOptionsRef = useRef<HTMLDivElement | null>(null);
const redirectionLink = `${process.env.NEXT_PUBLIC_APP_URL ? `${process.env.NEXT_PUBLIC_APP_URL}/create-workspace` : `${process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX === "1" ? `/god-mode/` : `/`}`}`; const redirectionLink = `${process.env.NEXT_PUBLIC_APP_URL ? `${process.env.NEXT_PUBLIC_APP_URL}/create-workspace` : `/god-mode/`}`;
return ( return (
<div <div

View File

@ -19,7 +19,7 @@ export const NewUserPopup: React.FC = observer(() => {
// theme // theme
const { resolvedTheme } = nextUseTheme(); const { resolvedTheme } = nextUseTheme();
const redirectionLink = `${process.env.NEXT_PUBLIC_APP_URL ? `${process.env.NEXT_PUBLIC_APP_URL}/create-workspace` : `${process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX === "1" ? `/god-mode/` : `/`}`}`; const redirectionLink = `${process.env.NEXT_PUBLIC_APP_URL ? `${process.env.NEXT_PUBLIC_APP_URL}/create-workspace` : `/god-mode/`}`;
if (!isNewUserPopup) return <></>; if (!isNewUserPopup) return <></>;
return ( return (

View File

@ -4,7 +4,7 @@ import { ReactElement, createContext } from "react";
// mobx store // mobx store
import { RootStore } from "@/store/root-store"; import { RootStore } from "@/store/root-store";
let rootStore = new RootStore(); export let rootStore = new RootStore();
export const StoreContext = createContext<RootStore>(rootStore); export const StoreContext = createContext<RootStore>(rootStore);

View File

@ -1,14 +1,14 @@
"use client"; "use client";
import { FC, ReactNode } from "react"; import { FC, ReactNode } from "react";
import { useRouter } from "next/navigation";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import useSWR from "swr"; import useSWR from "swr";
import { Spinner } from "@plane/ui"; import { Spinner } from "@plane/ui";
// hooks // hooks
import { useInstance, useUser } from "@/hooks"; import { useInstance, useUser } from "@/hooks";
// helpers // helpers
import { EAuthenticationPageType, EUserStatus } from "@/helpers"; import { EAuthenticationPageType } from "@/helpers";
import { redirect } from "next/navigation";
export interface IAuthWrapper { export interface IAuthWrapper {
children: ReactNode; children: ReactNode;
@ -16,41 +16,41 @@ export interface IAuthWrapper {
} }
export const AuthWrapper: FC<IAuthWrapper> = observer((props) => { export const AuthWrapper: FC<IAuthWrapper> = observer((props) => {
const router = useRouter();
// props
const { children, authType = EAuthenticationPageType.AUTHENTICATED } = props; const { children, authType = EAuthenticationPageType.AUTHENTICATED } = props;
// hooks // hooks
const { instance, fetchInstanceAdmins } = useInstance(); const { instance } = useInstance();
const { isLoading, userStatus, currentUser, fetchCurrentUser } = useUser(); const { isLoading, currentUser, fetchCurrentUser } = useUser();
useSWR("CURRENT_USER_DETAILS", () => fetchCurrentUser(), { const { isLoading: isSWRLoading } = useSWR("CURRENT_USER_DETAILS", () => fetchCurrentUser(), {
shouldRetryOnError: false,
});
useSWR("INSTANCE_ADMINS", () => fetchInstanceAdmins(), {
shouldRetryOnError: false, shouldRetryOnError: false,
}); });
if (isLoading) if (isSWRLoading || isLoading)
return ( return (
<div className="relative flex h-screen w-full items-center justify-center"> <div className="relative flex h-screen w-full items-center justify-center">
<Spinner /> <Spinner />
</div> </div>
); );
if (userStatus && userStatus?.status === EUserStatus.ERROR) if (authType === EAuthenticationPageType.NOT_AUTHENTICATED) {
return ( if (currentUser === undefined) return <>{children}</>;
<div className="relative flex h-screen w-screen items-center justify-center"> else {
Something went wrong. please try again later router.push("/general/");
</div> return <></>;
); }
}
if ([EAuthenticationPageType.AUTHENTICATED, EAuthenticationPageType.NOT_AUTHENTICATED].includes(authType)) { if (authType === EAuthenticationPageType.AUTHENTICATED) {
if (authType === EAuthenticationPageType.NOT_AUTHENTICATED) { if (currentUser) return <>{children}</>;
if (currentUser === undefined) return <>{children}</>; else {
else redirect("/general/"); if (instance && instance?.instance?.is_setup_done) {
} else { router.push("/");
if (currentUser) return <>{children}</>; return <></>;
else { } else {
if (instance?.instance?.is_setup_done) redirect("/login/"); router.push("/setup/");
else redirect("/setup/"); return <></>;
} }
} }
} }

View File

@ -12,7 +12,7 @@ import { InstanceNotReady } from "@/components/instance";
// hooks // hooks
import { useInstance } from "@/hooks"; import { useInstance } from "@/hooks";
// helpers // helpers
import { EInstancePageType, EInstanceStatus } from "@/helpers"; import { EInstancePageType } from "@/helpers";
type TInstanceWrapper = { type TInstanceWrapper = {
children: ReactNode; children: ReactNode;
@ -24,26 +24,19 @@ export const InstanceWrapper: FC<TInstanceWrapper> = observer((props) => {
const searchparams = useSearchParams(); const searchparams = useSearchParams();
const authEnabled = searchparams.get("auth_enabled") || "1"; const authEnabled = searchparams.get("auth_enabled") || "1";
// hooks // hooks
const { isLoading, instanceStatus, instance, fetchInstanceInfo } = useInstance(); const { isLoading, instance, fetchInstanceInfo } = useInstance();
useSWR("INSTANCE_INFORMATION", () => fetchInstanceInfo(), { const { isLoading: isSWRLoading } = useSWR("INSTANCE_INFORMATION", () => fetchInstanceInfo(), {
revalidateOnFocus: false, revalidateOnFocus: false,
}); });
if (isLoading) if (isSWRLoading || isLoading)
return ( return (
<div className="relative flex h-screen w-full items-center justify-center"> <div className="relative flex h-screen w-full items-center justify-center">
<Spinner /> <Spinner />
</div> </div>
); );
if (instanceStatus && instanceStatus?.status === EInstanceStatus.ERROR)
return (
<div className="relative flex h-screen w-screen items-center justify-center">
Something went wrong. please try again later
</div>
);
if (instance?.instance?.is_setup_done === false && authEnabled === "1") if (instance?.instance?.is_setup_done === false && authEnabled === "1")
return ( return (
<DefaultLayout withoutBackground> <DefaultLayout withoutBackground>

View File

@ -1,4 +1,6 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios"; import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
// store
import { rootStore } from "@/lib/store-context";
export abstract class APIService { export abstract class APIService {
protected baseURL: string; protected baseURL: string;
@ -18,7 +20,8 @@ export abstract class APIService {
this.axiosInstance.interceptors.response.use( this.axiosInstance.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
if (error.response && error.response.status === 401) window.location.href = "/login"; const store = rootStore;
if (error.response && error.response.status === 401 && store.user.currentUser) store.user.reset();
return Promise.reject(error); return Promise.reject(error);
} }
); );

View File

@ -18,8 +18,10 @@ export class RootStore {
} }
resetOnSignOut() { resetOnSignOut() {
this.theme = new ThemeStore(this); localStorage.setItem("theme", "system");
this.instance = new InstanceStore(this); this.instance = new InstanceStore(this);
this.user = new UserStore(this); this.user = new UserStore(this);
this.theme = new ThemeStore(this);
} }
} }

View File

@ -16,7 +16,8 @@ export interface IUserStore {
currentUser: IUser | undefined; currentUser: IUser | undefined;
// fetch actions // fetch actions
fetchCurrentUser: () => Promise<IUser>; fetchCurrentUser: () => Promise<IUser>;
signOut: () => Promise<void>; reset: () => void;
signOut: () => void;
} }
export class UserStore implements IUserStore { export class UserStore implements IUserStore {
@ -28,8 +29,6 @@ export class UserStore implements IUserStore {
// services // services
userService; userService;
authService; authService;
// rootStore
rootStore;
constructor(private store: RootStore) { constructor(private store: RootStore) {
makeObservable(this, { makeObservable(this, {
@ -40,10 +39,11 @@ export class UserStore implements IUserStore {
currentUser: observable, currentUser: observable,
// action // action
fetchCurrentUser: action, fetchCurrentUser: action,
reset: action,
signOut: action,
}); });
this.userService = new UserService(); this.userService = new UserService();
this.authService = new AuthService(); this.authService = new AuthService();
this.rootStore = store;
} }
/** /**
@ -54,11 +54,20 @@ export class UserStore implements IUserStore {
try { try {
if (this.currentUser === undefined) this.isLoading = true; if (this.currentUser === undefined) this.isLoading = true;
const currentUser = await this.userService.currentUser(); const currentUser = await this.userService.currentUser();
runInAction(() => { if (currentUser) {
this.isUserLoggedIn = true; await this.store.instance.fetchInstanceAdmins();
this.currentUser = currentUser; runInAction(() => {
this.isLoading = false; this.isUserLoggedIn = true;
}); this.currentUser = currentUser;
this.isLoading = false;
});
} else {
runInAction(() => {
this.isUserLoggedIn = false;
this.currentUser = undefined;
this.isLoading = false;
});
}
return currentUser; return currentUser;
} catch (error: any) { } catch (error: any) {
this.isLoading = false; this.isLoading = false;
@ -77,7 +86,14 @@ export class UserStore implements IUserStore {
} }
}; };
reset = async () => {
this.isUserLoggedIn = false;
this.currentUser = undefined;
this.isLoading = false;
this.userStatus = undefined;
};
signOut = async () => { signOut = async () => {
this.rootStore.resetOnSignOut(); this.store.resetOnSignOut();
}; };
} }

View File

@ -1,7 +1,7 @@
# Backend # Backend
# Debug value for api server use it as 0 for production use # Debug value for api server use it as 0 for production use
DEBUG=0 DEBUG=0
CORS_ALLOWED_ORIGINS="" CORS_ALLOWED_ORIGINS="http://localhost"
# Error logs # Error logs
SENTRY_DSN="" SENTRY_DSN=""

View File

@ -602,11 +602,19 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView):
@cache_response(60 * 60 * 24, user=False) @cache_response(60 * 60 * 24, user=False)
def get(self, request): def get(self, request):
files = [] files = []
s3 = boto3.client( if settings.USE_MINIO:
"s3", s3 = boto3.client(
aws_access_key_id=settings.AWS_ACCESS_KEY_ID, "s3",
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, 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 = { params = {
"Bucket": settings.AWS_STORAGE_BUCKET_NAME, "Bucket": settings.AWS_STORAGE_BUCKET_NAME,
"Prefix": "static/project-cover/", "Prefix": "static/project-cover/",

View File

@ -1,5 +1,7 @@
# Django imports # Django imports
from django.db.models import Case, Count, IntegerField, Q, When from django.db.models import Case, Count, IntegerField, Q, When
from django.contrib.auth import logout
from django.utils import timezone
# Third party imports # Third party imports
from rest_framework import status from rest_framework import status
@ -26,6 +28,7 @@ from plane.db.models import (
from plane.license.models import Instance, InstanceAdmin from plane.license.models import Instance, InstanceAdmin
from plane.utils.cache import cache_response, invalidate_cache from plane.utils.cache import cache_response, invalidate_cache
from plane.utils.paginator import BasePaginator from plane.utils.paginator import BasePaginator
from plane.authentication.utils.host import user_ip
class UserEndpoint(BaseViewSet): class UserEndpoint(BaseViewSet):
@ -166,7 +169,14 @@ class UserEndpoint(BaseViewSet):
"workspace_invite": False, "workspace_invite": False,
} }
profile.save() profile.save()
# User log out
user.last_logout_ip = user_ip(request=request)
user.last_logout_time = timezone.now()
user.save() user.save()
# Logout the user
logout(request)
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -7,12 +7,6 @@ def auth_exception_handler(exc, context):
response = exception_handler(exc, context) response = exception_handler(exc, context)
# Check if an AuthenticationFailed exception is raised. # Check if an AuthenticationFailed exception is raised.
if isinstance(exc, NotAuthenticated): if isinstance(exc, NotAuthenticated):
# Return 403 if the users me api fails response.status_code = 401
request = context["request"]
if request.path == "/api/users/me/":
response.status_code = 403
# else return 401
else:
response.status_code = 401
return response return response

View File

@ -46,9 +46,7 @@ class GitHubOAuthProvider(OauthAdapter):
client_id = GITHUB_CLIENT_ID client_id = GITHUB_CLIENT_ID
client_secret = GITHUB_CLIENT_SECRET client_secret = GITHUB_CLIENT_SECRET
redirect_uri = ( redirect_uri = f"""{"https" if request.is_secure() else "http"}://{request.get_host()}/auth/github/callback/"""
f"{request.scheme}://{request.get_host()}/auth/github/callback/"
)
url_params = { url_params = {
"client_id": client_id, "client_id": client_id,
"redirect_uri": redirect_uri, "redirect_uri": redirect_uri,

View File

@ -43,9 +43,7 @@ class GoogleOAuthProvider(OauthAdapter):
client_id = GOOGLE_CLIENT_ID client_id = GOOGLE_CLIENT_ID
client_secret = GOOGLE_CLIENT_SECRET client_secret = GOOGLE_CLIENT_SECRET
redirect_uri = ( redirect_uri = f"""{"https" if request.is_secure() else "http"}://{request.get_host()}/auth/google/callback/"""
f"{request.scheme}://{request.get_host()}/auth/google/callback/"
)
url_params = { url_params = {
"client_id": client_id, "client_id": client_id,
"scope": self.scope, "scope": self.scope,

View File

@ -6,7 +6,7 @@ def base_host(request):
return ( return (
request.META.get("HTTP_ORIGIN") request.META.get("HTTP_ORIGIN")
or f"{urlsplit(request.META.get('HTTP_REFERER')).scheme}://{urlsplit(request.META.get('HTTP_REFERER')).netloc}" 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()}"""
) )

View File

@ -10,10 +10,13 @@ def get_redirection_path(user):
return "onboarding" return "onboarding"
# Redirect to the last workspace if the user has last workspace # Redirect to the last workspace if the user has last workspace
if profile.last_workspace_id and Workspace.objects.filter( if (
pk=profile.last_workspace_id, profile.last_workspace_id
workspace_member__member_id=user.id, and Workspace.objects.filter(
workspace_member__is_active=True, pk=profile.last_workspace_id,
workspace_member__member_id=user.id,
workspace_member__is_active=True,
).exists()
): ):
workspace = Workspace.objects.filter( workspace = Workspace.objects.filter(
pk=profile.last_workspace_id, pk=profile.last_workspace_id,

View File

@ -206,7 +206,7 @@ class ResetPasswordEndpoint(View):
url = urljoin( url = urljoin(
base_host(request=request), base_host(request=request),
"accounts/sign-in?" + urlencode({"success", True}), "accounts/sign-in?" + urlencode({"success": True}),
) )
return HttpResponseRedirect(url) return HttpResponseRedirect(url)
except DjangoUnicodeDecodeError: except DjangoUnicodeDecodeError:

View File

@ -31,6 +31,8 @@ MEDIA_URL = "/uploads/"
MEDIA_ROOT = os.path.join(BASE_DIR, "uploads") # noqa MEDIA_ROOT = os.path.join(BASE_DIR, "uploads") # noqa
CORS_ALLOWED_ORIGINS = [ CORS_ALLOWED_ORIGINS = [
"http://localhost",
"http://127.0.0.1",
"http://localhost:3000", "http://localhost:3000",
"http://127.0.0.1:3000", "http://127.0.0.1:3000",
"http://localhost:4000", "http://localhost:4000",

View File

@ -60,4 +60,4 @@ zxcvbn==4.4.28
# timezone # timezone
pytz==2024.1 pytz==2024.1
# jwt # jwt
jwt==1.3.1 PyJWT==2.8.0

View File

@ -73,6 +73,20 @@ services:
- worker - worker
- web - web
admin:
build:
context: .
dockerfile: ./admin/Dockerfile.dev
restart: unless-stopped
networks:
- dev_env
volumes:
- ./admin:/app/admin
depends_on:
- api
- worker
- web
api: api:
build: build:
context: ./apiserver context: ./apiserver
@ -167,3 +181,4 @@ services:
- web - web
- api - api
- space - space
- admin

View File

@ -2,5 +2,6 @@
export dollar="$" export dollar="$"
export http_upgrade="http_upgrade" export http_upgrade="http_upgrade"
export scheme="scheme"
envsubst < /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf envsubst < /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf
exec nginx -g 'daemon off;' exec nginx -g 'daemon off;'

View File

@ -15,6 +15,8 @@ http {
add_header Referrer-Policy "no-referrer-when-downgrade" always; add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Permissions-Policy "interest-cohort=()" always; add_header Permissions-Policy "interest-cohort=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Forwarded-Proto "${dollar}scheme";
add_header Host "${dollar}host";
location / { location / {
proxy_pass http://web:3000/; proxy_pass http://web:3000/;
@ -23,8 +25,8 @@ http {
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
} }
location /god-mode { location /god-mode/ {
proxy_pass http://godmode:3000/; proxy_pass http://admin:3000/god-mode/;
} }
location /api/ { location /api/ {

View File

@ -15,6 +15,8 @@ http {
add_header Referrer-Policy "no-referrer-when-downgrade" always; add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Permissions-Policy "interest-cohort=()" always; add_header Permissions-Policy "interest-cohort=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Forwarded-Proto "${dollar}scheme";
add_header Host "${dollar}host";
location / { location / {
proxy_pass http://web:3000/; proxy_pass http://web:3000/;

View File

@ -7,6 +7,8 @@ export LC_CTYPE=C
cp ./web/.env.example ./web/.env cp ./web/.env.example ./web/.env
cp ./apiserver/.env.example ./apiserver/.env cp ./apiserver/.env.example ./apiserver/.env
cp ./space/.env.example ./space/.env
cp ./admin/.env.example ./admin/.env
# Generate the SECRET_KEY that will be used by django # Generate the SECRET_KEY that will be used by django
echo "SECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./apiserver/.env echo "SECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./apiserver/.env

2
space/.env.example Normal file
View File

@ -0,0 +1,2 @@
NEXT_PUBLIC_APP_URL=
NEXT_PUBLIC_API_BASE_URL=

View File

@ -1,7 +1,7 @@
import { useContext } from "react"; import { useContext } from "react";
// store // store
import { StoreContext } from "@/lib/store-context"; import { StoreContext } from "@/lib/store-context";
import { IUserStore } from "@/store/user/index.store"; import { IUserStore } from "@/store/user";
export const useUser = (): IUserStore => { export const useUser = (): IUserStore => {
const context = useContext(StoreContext); const context = useContext(StoreContext);

View File

@ -2,7 +2,7 @@ import { ReactElement, createContext } from "react";
// mobx store // mobx store
import { RootStore } from "@/store/root.store"; import { RootStore } from "@/store/root.store";
let rootStore = new RootStore(); export let rootStore = new RootStore();
export const StoreContext = createContext<RootStore>(rootStore); export const StoreContext = createContext<RootStore>(rootStore);

View File

@ -11,7 +11,7 @@ import { StoreProvider } from "@/lib/store-context";
// wrappers // wrappers
import { InstanceWrapper } from "@/lib/wrappers"; import { InstanceWrapper } from "@/lib/wrappers";
const prefix = parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0") === 0 ? "/" : "/spaces/"; const prefix = "/spaces/";
function MyApp({ Component, pageProps }: AppProps) { function MyApp({ Component, pageProps }: AppProps) {
return ( return (

View File

@ -1,5 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import axios, { AxiosInstance } from "axios"; import axios, { AxiosInstance } from "axios";
// store
import { rootStore } from "@/lib/store-context";
abstract class APIService { abstract class APIService {
protected baseURL: string; protected baseURL: string;
@ -19,7 +21,8 @@ abstract class APIService {
this.axiosInstance.interceptors.response.use( this.axiosInstance.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
if (error.response && error.response.status === 401) window.location.href = "/"; const store = rootStore;
if (error.response && error.response.status === 401 && store.user.data) store.user.reset();
return Promise.reject(error); return Promise.reject(error);
} }
); );

View File

@ -3,7 +3,7 @@ import { enableStaticRendering } from "mobx-react-lite";
// store imports // store imports
import { IInstanceStore, InstanceStore } from "@/store/instance.store"; import { IInstanceStore, InstanceStore } from "@/store/instance.store";
import { IProjectStore, ProjectStore } from "@/store/project"; import { IProjectStore, ProjectStore } from "@/store/project";
import { IUserStore, UserStore } from "@/store/user/index.store"; import { IUserStore, UserStore } from "@/store/user";
import { IProfileStore, ProfileStore } from "@/store/user/profile.store"; import { IProfileStore, ProfileStore } from "@/store/user/profile.store";
import IssueStore, { IIssueStore } from "./issue"; import IssueStore, { IIssueStore } from "./issue";

View File

@ -2,8 +2,6 @@ import set from "lodash/set";
import { action, computed, makeObservable, observable, runInAction } from "mobx"; import { action, computed, makeObservable, observable, runInAction } from "mobx";
// types // types
import { IUser } from "@plane/types"; import { IUser } from "@plane/types";
// helpers
// import { API_BASE_URL } from "@/helpers/common.helper";
// services // services
import { AuthService } from "@/services/authentication.service"; import { AuthService } from "@/services/authentication.service";
import { UserService } from "@/services/user.service"; import { UserService } from "@/services/user.service";
@ -30,6 +28,7 @@ export interface IUserStore {
// actions // actions
fetchCurrentUser: () => Promise<IUser | undefined>; fetchCurrentUser: () => Promise<IUser | undefined>;
updateCurrentUser: (data: Partial<IUser>) => Promise<IUser | undefined>; updateCurrentUser: (data: Partial<IUser>) => Promise<IUser | undefined>;
reset: () => void;
signOut: () => Promise<void>; signOut: () => Promise<void>;
} }
@ -65,6 +64,7 @@ export class UserStore implements IUserStore {
// actions // actions
fetchCurrentUser: action, fetchCurrentUser: action,
updateCurrentUser: action, updateCurrentUser: action,
reset: action,
signOut: action, signOut: action,
}); });
} }
@ -153,6 +153,20 @@ export class UserStore implements IUserStore {
} }
}; };
/**
* @description resets the user store
* @returns {void}
*/
reset = (): void => {
runInAction(() => {
this.isAuthenticated = false;
this.isLoading = false;
this.error = undefined;
this.data = undefined;
this.userProfile = new ProfileStore(this.store);
});
};
/** /**
* @description signs out the current user * @description signs out the current user
* @returns {Promise<void>} * @returns {Promise<void>}

View File

@ -88,7 +88,10 @@ export const AuthRoot: FC<TAuthRoot> = observer((props) => {
if (authMode === EAuthModes.SIGN_IN) { if (authMode === EAuthModes.SIGN_IN) {
if (response.is_password_autoset) setAuthStep(EAuthSteps.UNIQUE_CODE); if (response.is_password_autoset) setAuthStep(EAuthSteps.UNIQUE_CODE);
else setAuthStep(EAuthSteps.PASSWORD); else setAuthStep(EAuthSteps.PASSWORD);
} else setAuthStep(EAuthSteps.PASSWORD); } else {
if (instance && instance?.config?.is_smtp_configured) setAuthStep(EAuthSteps.UNIQUE_CODE);
else setAuthStep(EAuthSteps.PASSWORD);
}
}) })
.catch((error) => { .catch((error) => {
const errorhandler = authErrorHandler(error?.error_code.toString(), data?.email || undefined); const errorhandler = authErrorHandler(error?.error_code.toString(), data?.email || undefined);

View File

@ -40,15 +40,22 @@ const authService = new AuthService();
export const AuthPasswordForm: React.FC<Props> = observer((props: Props) => { export const AuthPasswordForm: React.FC<Props> = observer((props: Props) => {
const { email, handleStepChange, handleEmailClear, mode } = props; const { email, handleStepChange, handleEmailClear, mode } = props;
// states
const [passwordFormData, setPasswordFormData] = useState<TPasswordFormValues>({ ...defaultValues, email });
const [showPassword, setShowPassword] = useState(false);
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
// hooks // hooks
const { instance } = useInstance(); const { instance } = useInstance();
const { captureEvent } = useEventTracker(); const { captureEvent } = useEventTracker();
// states
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
const [passwordFormData, setPasswordFormData] = useState<TPasswordFormValues>({ ...defaultValues, email });
const [showPassword, setShowPassword] = useState({
password: false,
retypePassword: false,
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false);
const handleShowPassword = (key: keyof typeof showPassword) =>
setShowPassword((prev) => ({ ...prev, [key]: !prev[key] }));
// derived values // derived values
const isSmtpConfigured = instance?.config?.is_smtp_configured; const isSmtpConfigured = instance?.config?.is_smtp_configured;
@ -116,9 +123,9 @@ export const AuthPasswordForm: React.FC<Props> = observer((props: Props) => {
type="email" type="email"
value={passwordFormData.email} value={passwordFormData.email}
onChange={(e) => handleFormChange("email", e.target.value)} onChange={(e) => handleFormChange("email", e.target.value)}
// hasError={Boolean(errors.email)}
placeholder="name@company.com" placeholder="name@company.com"
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
disabled
/> />
{passwordFormData.email.length > 0 && ( {passwordFormData.email.length > 0 && (
<XCircle <XCircle
@ -127,6 +134,7 @@ export const AuthPasswordForm: React.FC<Props> = observer((props: Props) => {
/> />
)} )}
</div> </div>
<input type="hidden" value={passwordFormData.email} name="email" />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="password"> <label className="text-sm text-onboarding-text-300 font-medium" htmlFor="password">
@ -134,7 +142,7 @@ export const AuthPasswordForm: React.FC<Props> = observer((props: Props) => {
</label> </label>
<div className="relative flex items-center rounded-md bg-onboarding-background-200"> <div className="relative flex items-center rounded-md bg-onboarding-background-200">
<Input <Input
type={showPassword ? "text" : "password"} type={showPassword?.password ? "text" : "password"}
name="password" name="password"
value={passwordFormData.password} value={passwordFormData.password}
onChange={(e) => handleFormChange("password", e.target.value)} onChange={(e) => handleFormChange("password", e.target.value)}
@ -144,15 +152,15 @@ export const AuthPasswordForm: React.FC<Props> = observer((props: Props) => {
onBlur={() => setIsPasswordInputFocused(false)} onBlur={() => setIsPasswordInputFocused(false)}
autoFocus autoFocus
/> />
{showPassword ? ( {showPassword?.password ? (
<EyeOff <EyeOff
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer" className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => setShowPassword(false)} onClick={() => handleShowPassword("password")}
/> />
) : ( ) : (
<Eye <Eye
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer" className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => setShowPassword(true)} onClick={() => handleShowPassword("password")}
/> />
)} )}
</div> </div>
@ -165,22 +173,22 @@ export const AuthPasswordForm: React.FC<Props> = observer((props: Props) => {
</label> </label>
<div className="relative flex items-center rounded-md bg-onboarding-background-200"> <div className="relative flex items-center rounded-md bg-onboarding-background-200">
<Input <Input
type={showPassword ? "text" : "password"} type={showPassword?.retypePassword ? "text" : "password"}
name="confirm_password" name="confirm_password"
value={passwordFormData.confirm_password} value={passwordFormData.confirm_password}
onChange={(e) => handleFormChange("confirm_password", e.target.value)} onChange={(e) => handleFormChange("confirm_password", e.target.value)}
placeholder="Confirm password" placeholder="Confirm password"
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
/> />
{showPassword ? ( {showPassword?.retypePassword ? (
<EyeOff <EyeOff
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer" className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => setShowPassword(false)} onClick={() => handleShowPassword("retypePassword")}
/> />
) : ( ) : (
<Eye <Eye
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer" className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => setShowPassword(true)} onClick={() => handleShowPassword("retypePassword")}
/> />
)} )}
</div> </div>

View File

@ -111,10 +111,9 @@ export const AuthUniqueCodeForm: React.FC<Props> = (props) => {
type="email" type="email"
value={uniqueCodeFormData.email} value={uniqueCodeFormData.email}
onChange={(e) => handleFormChange("email", e.target.value)} onChange={(e) => handleFormChange("email", e.target.value)}
// FIXME:
// hasError={Boolean(errors.email)}
placeholder="name@company.com" placeholder="name@company.com"
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
disabled
/> />
{uniqueCodeFormData.email.length > 0 && ( {uniqueCodeFormData.email.length > 0 && (
<XCircle <XCircle
@ -122,6 +121,7 @@ export const AuthUniqueCodeForm: React.FC<Props> = (props) => {
onClick={handleEmailClear} onClick={handleEmailClear}
/> />
)} )}
<input type="hidden" value={uniqueCodeFormData.email} name="email" />
</div> </div>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">

View File

@ -1,7 +1,5 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useTheme } from "next-themes";
import { mutate } from "swr";
import { Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
// hooks // hooks
@ -15,17 +13,14 @@ type Props = {
}; };
export const DeactivateAccountModal: React.FC<Props> = (props) => { export const DeactivateAccountModal: React.FC<Props> = (props) => {
const router = useRouter();
const { isOpen, onClose } = props; const { isOpen, onClose } = props;
// hooks
const { deactivateAccount, signOut } = useUser();
// states // states
const [isDeactivating, setIsDeactivating] = useState(false); const [isDeactivating, setIsDeactivating] = useState(false);
const { deactivateAccount } = useUser();
const router = useRouter();
const { setTheme } = useTheme();
const handleClose = () => { const handleClose = () => {
setIsDeactivating(false); setIsDeactivating(false);
onClose(); onClose();
@ -41,8 +36,7 @@ export const DeactivateAccountModal: React.FC<Props> = (props) => {
title: "Success!", title: "Success!",
message: "Account deactivated successfully.", message: "Account deactivated successfully.",
}); });
mutate("CURRENT_USER_DETAILS", null); signOut();
setTheme("system");
router.push("/"); router.push("/");
handleClose(); handleClose();
}) })

View File

@ -2,7 +2,7 @@ import { ReactElement, createContext } from "react";
// mobx store // mobx store
import { RootStore } from "@/store/root.store"; import { RootStore } from "@/store/root.store";
let rootStore = new RootStore(); export let rootStore = new RootStore();
export const StoreContext = createContext<RootStore>(rootStore); export const StoreContext = createContext<RootStore>(rootStore);

View File

@ -1,5 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import axios, { AxiosInstance } from "axios"; import axios, { AxiosInstance } from "axios";
// store
import { rootStore } from "@/lib/store-context";
export abstract class APIService { export abstract class APIService {
protected baseURL: string; protected baseURL: string;
@ -19,7 +21,11 @@ export abstract class APIService {
this.axiosInstance.interceptors.response.use( this.axiosInstance.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
if (error.response && error.response.status === 401) window.location.href = "/accounts/sign-in"; const store = rootStore;
if (error.response && error.response.status === 401 && store.user.data) {
store.user.reset();
store.resetOnSignOut();
}
return Promise.reject(error); return Promise.reject(error);
} }
); );

View File

@ -36,6 +36,7 @@ export interface IUserStore {
updateCurrentUser: (data: Partial<IUser>) => Promise<IUser | undefined>; updateCurrentUser: (data: Partial<IUser>) => Promise<IUser | undefined>;
handleSetPassword: (csrfToken: string, data: { password: string }) => Promise<IUser | undefined>; handleSetPassword: (csrfToken: string, data: { password: string }) => Promise<IUser | undefined>;
deactivateAccount: () => Promise<void>; deactivateAccount: () => Promise<void>;
reset: () => void;
signOut: () => Promise<void>; signOut: () => Promise<void>;
} }
@ -79,6 +80,7 @@ export class UserStore implements IUserStore {
updateCurrentUser: action, updateCurrentUser: action,
handleSetPassword: action, handleSetPassword: action,
deactivateAccount: action, deactivateAccount: action,
reset: action,
signOut: action, signOut: action,
}); });
} }
@ -191,6 +193,22 @@ export class UserStore implements IUserStore {
this.store.resetOnSignOut(); this.store.resetOnSignOut();
}; };
/**
* @description resets the user store
* @returns {void}
*/
reset = (): void => {
runInAction(() => {
this.isAuthenticated = false;
this.isLoading = false;
this.error = undefined;
this.data = undefined;
this.userProfile = new ProfileStore(this.store);
this.userSettings = new UserSettingsStore();
this.membership = new UserMembershipStore(this.store);
});
};
/** /**
* @description signs out the current user * @description signs out the current user
* @returns {Promise<void>} * @returns {Promise<void>}

View File

@ -2658,7 +2658,7 @@
resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-3.0.5.tgz#1e78a3ac2428e6d7e6c05c1665c242023a4601d8" resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-3.0.5.tgz#1e78a3ac2428e6d7e6c05c1665c242023a4601d8"
integrity sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw== integrity sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==
"@types/lodash@^4.14.202", "@types/lodash@^4.17.0": "@types/lodash@^4.14.202", "@types/lodash@^4.17.0", "@types/lodash@^4.17.1":
version "4.17.1" version "4.17.1"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.1.tgz#0fabfcf2f2127ef73b119d98452bd317c4a17eb8" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.1.tgz#0fabfcf2f2127ef73b119d98452bd317c4a17eb8"
integrity sha512-X+2qazGS3jxLAIz5JDXDzglAF3KpijdhFxlf/V1+hEsOUc+HnWi81L/uv/EvGuV90WY+7mPGFCUDGfQC3Gj95Q== integrity sha512-X+2qazGS3jxLAIz5JDXDzglAF3KpijdhFxlf/V1+hEsOUc+HnWi81L/uv/EvGuV90WY+7mPGFCUDGfQC3Gj95Q==
@ -6935,10 +6935,10 @@ postcss@^8.4.21, postcss@^8.4.23, postcss@^8.4.29:
picocolors "^1.0.0" picocolors "^1.0.0"
source-map-js "^1.2.0" source-map-js "^1.2.0"
posthog-js@^1.105.0: posthog-js@^1.131.3:
version "1.131.2" version "1.131.3"
resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.131.2.tgz#c82b16a4074f773eaf41df47187fb88cc5aef28c" resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.131.3.tgz#bd3e6123dc715f089825a92d3ec62480b7ec0a76"
integrity sha512-un5c5CbDhJ1LRBDgy4I1D5a1++P8/mNl4CS9C5A1z95qIF7iY8OuA6XPW7sIA6tKSdda4PGwfa2Gmfz1nvnywQ== integrity sha512-ds/TADDS+rT/WgUyeW4cJ+X+fX+O1KdkOyssNI/tP90PrFf0IJsck5B42YOLhfz87U2vgTyBaKHkdlMgWuOFog==
dependencies: dependencies:
fflate "^0.4.8" fflate "^0.4.8"
preact "^10.19.3" preact "^10.19.3"