Authentication Workflow fixes. Redirection fixes (#832)

* auth integration fixes

* auth integration fixes

* auth integration fixes

* auth integration fixes

* dev: update user api to return fallback workspace and improve the structure of the response

* dev: fix the issue keyerror and move onboarding logic to serializer method field

* dev: use-user-auth hook imlemented for route access validation and build issues resolved effected by user payload

* fix: global theme color fix

* style: new onboarding ui , fix: use-user-auth hook implemented

* fix: command palette, project invite modal and issue detail page mutation type fix

* fix: onboarding redirection fix

* dev: build isuue resolved

* fix: use user auth hook fix

* fix: sign in toast alert fix, sign out redirection fix and user theme error fix

* fix: user response fix

* fix: unAuthorizedStatus logic updated

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
Co-authored-by: gurusainath <gurusainath007@gmail.com>
Co-authored-by: anmolsinghbhatia <anmolsinghbhatia@caravel.tech>
This commit is contained in:
sriram veeraghanta 2023-05-30 09:44:35 -04:00 committed by GitHub
parent 33db616767
commit 44f8ba407d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 821 additions and 593 deletions

View File

@ -25,6 +25,10 @@ class UserSerializer(BaseSerializer):
] ]
extra_kwargs = {"password": {"write_only": True}} extra_kwargs = {"password": {"write_only": True}}
# If the user has already filled first name or last name then he is onboarded
def get_is_onboarded(self, obj):
return bool(obj.first_name) or bool(obj.last_name)
class UserLiteSerializer(BaseSerializer): class UserLiteSerializer(BaseSerializer):
class Meta: class Meta:

View File

@ -31,36 +31,61 @@ class UserEndpoint(BaseViewSet):
def retrieve(self, request): def retrieve(self, request):
try: try:
workspace = Workspace.objects.get(pk=request.user.last_workspace_id) workspace = Workspace.objects.get(
pk=request.user.last_workspace_id, workspace_member__member=request.user
)
workspace_invites = WorkspaceMemberInvite.objects.filter( workspace_invites = WorkspaceMemberInvite.objects.filter(
email=request.user.email email=request.user.email
).count() ).count()
assigned_issues = Issue.objects.filter(assignees__in=[request.user]).count() assigned_issues = Issue.objects.filter(assignees__in=[request.user]).count()
serialized_data = UserSerializer(request.user).data
serialized_data["workspace"] = {
"last_workspace_id": request.user.last_workspace_id,
"last_workspace_slug": workspace.slug,
"fallback_workspace_id": request.user.last_workspace_id,
"fallback_workspace_slug": workspace.slug,
"invites": workspace_invites,
}
serialized_data.setdefault("issues", {})["assigned_issues"] = assigned_issues
return Response( return Response(
{ serialized_data,
"user": UserSerializer(request.user).data,
"slug": workspace.slug,
"workspace_invites": workspace_invites,
"assigned_issues": assigned_issues,
},
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
except Workspace.DoesNotExist: except Workspace.DoesNotExist:
# This exception will be hit even when the `last_workspace_id` is None
workspace_invites = WorkspaceMemberInvite.objects.filter( workspace_invites = WorkspaceMemberInvite.objects.filter(
email=request.user.email email=request.user.email
).count() ).count()
assigned_issues = Issue.objects.filter(assignees__in=[request.user]).count() assigned_issues = Issue.objects.filter(assignees__in=[request.user]).count()
fallback_workspace = Workspace.objects.filter(
workspace_member__member=request.user
).order_by("created_at").first()
serialized_data = UserSerializer(request.user).data
serialized_data["workspace"] = {
"last_workspace_id": None,
"last_workspace_slug": None,
"fallback_workspace_id": fallback_workspace.id
if fallback_workspace is not None
else None,
"fallback_workspace_slug": fallback_workspace.slug
if fallback_workspace is not None
else None,
"invites": workspace_invites,
}
serialized_data.setdefault("issues", {})["assigned_issues"] = assigned_issues
return Response( return Response(
{ serialized_data,
"user": UserSerializer(request.user).data,
"slug": None,
"workspace_invites": workspace_invites,
"assigned_issues": assigned_issues,
},
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
except Exception as e: except Exception as e:
capture_exception(e)
return Response( return Response(
{"error": "Something went wrong please try again later"}, {"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,

View File

@ -66,8 +66,12 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
const handleSignin = async (formData: EmailCodeFormValues) => { const handleSignin = async (formData: EmailCodeFormValues) => {
await authenticationService await authenticationService
.magicSignIn(formData) .magicSignIn(formData)
.then((response) => { .then(() => {
onSuccess(response); setToastAlert({
title: "Success",
type: "success",
message: "Successfully logged in!",
});
}) })
.catch((error) => { .catch((error) => {
setToastAlert({ setToastAlert({

View File

@ -1,24 +0,0 @@
import { useState, FC } from "react";
import { KeyIcon } from "@heroicons/react/24/outline";
// components
import { EmailCodeForm, EmailPasswordForm } from "components/account";
export interface EmailSignInFormProps {
handleSuccess: () => void;
}
export const EmailSignInForm: FC<EmailSignInFormProps> = (props) => {
const { handleSuccess } = props;
// states
const [useCode, setUseCode] = useState(true);
return (
<>
{useCode ? (
<EmailCodeForm onSuccess={handleSuccess} />
) : (
<EmailPasswordForm onSuccess={handleSuccess} />
)}
</>
);
};

View File

@ -29,7 +29,7 @@ export const GithubLoginButton: FC<GithubLoginButtonProps> = (props) => {
useEffect(() => { useEffect(() => {
const origin = const origin =
typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
setLoginCallBackURL(`${origin}/signin` as any); setLoginCallBackURL(`${origin}/` as any);
}, []); }, []);
return ( return (

View File

@ -2,4 +2,3 @@ export * from "./google-login";
export * from "./email-code-form"; export * from "./email-code-form";
export * from "./email-password-form"; export * from "./email-password-form";
export * from "./github-login-button"; export * from "./github-login-button";
export * from "./email-signin-form";

View File

@ -39,7 +39,7 @@ export const NotAuthorizedView: React.FC<Props> = ({ actionButton, type }) => {
{user ? ( {user ? (
<p> <p>
You have signed in as {user.email}. <br /> You have signed in as {user.email}. <br />
<Link href={`/signin?next=${currentPath}`}> <Link href={`/?next=${currentPath}`}>
<a className="font-medium text-brand-base">Sign in</a> <a className="font-medium text-brand-base">Sign in</a>
</Link>{" "} </Link>{" "}
with different account that has access to this page. with different account that has access to this page.
@ -47,7 +47,7 @@ export const NotAuthorizedView: React.FC<Props> = ({ actionButton, type }) => {
) : ( ) : (
<p> <p>
You need to{" "} You need to{" "}
<Link href={`/signin?next=${currentPath}`}> <Link href={`/?next=${currentPath}`}>
<a className="font-medium text-brand-base">Sign in</a> <a className="font-medium text-brand-base">Sign in</a>
</Link>{" "} </Link>{" "}
with an account that has access to this page. with an account that has access to this page.

View File

@ -3,6 +3,7 @@ import { useRouter } from "next/router";
import Link from "next/link"; import Link from "next/link";
// icons // icons
import { ArrowLeftIcon } from "@heroicons/react/24/outline"; import { ArrowLeftIcon } from "@heroicons/react/24/outline";
import { Icon } from "components/ui";
type BreadcrumbsProps = { type BreadcrumbsProps = {
children: any; children: any;
@ -16,10 +17,13 @@ const Breadcrumbs = ({ children }: BreadcrumbsProps) => {
<div className="flex items-center"> <div className="flex items-center">
<button <button
type="button" type="button"
className="grid h-8 w-8 flex-shrink-0 cursor-pointer place-items-center rounded border border-brand-base text-center text-sm hover:bg-brand-surface-1" className="group grid h-7 w-7 flex-shrink-0 cursor-pointer place-items-center rounded border border-brand-base text-center text-sm hover:bg-brand-surface-1"
onClick={() => router.back()} onClick={() => router.back()}
> >
<ArrowLeftIcon className="h-3 w-3" /> <Icon
iconName="keyboard_backspace"
className="text-base leading-4 text-brand-secondary group-hover:text-brand-base"
/>
</button> </button>
{children} {children}
</div> </div>

View File

@ -57,12 +57,15 @@ export const ChangeIssueAssignee: React.FC<Props> = ({ setIsPaletteOpen, issue }
async (formData: Partial<IIssue>) => { async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueId) return; if (!workspaceSlug || !projectId || !issueId) return;
mutate( mutate<IIssue>(
ISSUE_DETAILS(issueId as string), ISSUE_DETAILS(issueId as string),
(prevData: IIssue) => ({ async (prevData) => {
...prevData, if (!prevData) return prevData;
...formData, return {
}), ...prevData,
...formData,
};
},
false false
); );
@ -80,7 +83,7 @@ export const ChangeIssueAssignee: React.FC<Props> = ({ setIsPaletteOpen, issue }
); );
const handleIssueAssignees = (assignee: string) => { const handleIssueAssignees = (assignee: string) => {
const updatedAssignees = issue.assignees ?? []; const updatedAssignees = issue.assignees_list ?? [];
if (updatedAssignees.includes(assignee)) { if (updatedAssignees.includes(assignee)) {
updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1); updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1);

View File

@ -27,12 +27,16 @@ export const ChangeIssuePriority: React.FC<Props> = ({ setIsPaletteOpen, issue }
async (formData: Partial<IIssue>) => { async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueId) return; if (!workspaceSlug || !projectId || !issueId) return;
mutate( mutate<IIssue>(
ISSUE_DETAILS(issueId as string), ISSUE_DETAILS(issueId as string),
(prevData: IIssue) => ({ async (prevData) => {
...prevData, if (!prevData) return prevData;
...formData,
}), return {
...prevData,
...formData,
};
},
false false
); );

View File

@ -39,12 +39,15 @@ export const ChangeIssueState: React.FC<Props> = ({ setIsPaletteOpen, issue }) =
async (formData: Partial<IIssue>) => { async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueId) return; if (!workspaceSlug || !projectId || !issueId) return;
mutate( mutate<IIssue>(
ISSUE_DETAILS(issueId as string), ISSUE_DETAILS(issueId as string),
(prevData: IIssue) => ({ async (prevData) => {
...prevData, if (!prevData) return prevData;
...formData, return {
}), ...prevData,
...formData,
};
},
false false
); );

View File

@ -120,12 +120,17 @@ export const CommandPalette: React.FC = () => {
async (formData: Partial<IIssue>) => { async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueId) return; if (!workspaceSlug || !projectId || !issueId) return;
mutate( mutate<IIssue>(
ISSUE_DETAILS(issueId as string), ISSUE_DETAILS(issueId as string),
(prevData: IIssue) => ({
...prevData, (prevData) => {
...formData, if (!prevData) return prevData;
}),
return {
...prevData,
...formData,
};
},
false false
); );

View File

@ -45,8 +45,9 @@ export const InviteMembers: React.FC<Props> = ({ setStep, workspace }) => {
> >
<div className="flex w-full max-w-xl flex-col gap-12"> <div className="flex w-full max-w-xl flex-col gap-12">
<div className="flex flex-col gap-6 rounded-[10px] bg-brand-base p-7 shadow-md"> <div className="flex flex-col gap-6 rounded-[10px] bg-brand-base p-7 shadow-md">
<h2 className="text-xl font-medium">Invite your team to your workspace.</h2> <h2 className="text-xl font-medium">Invite co-workers to your team</h2>
<div className="flex flex-col items-start justify-center gap-2.5"> <div className="flex flex-col items-start justify-center gap-2.5">
<span>Email</span>
<div className="w-full"> <div className="w-full">
<MultiInput <MultiInput
name="emails" name="emails"

View File

@ -16,7 +16,7 @@ type Props = {
export const OnboardingCard: React.FC<Props> = ({ data, gradient = false }) => ( export const OnboardingCard: React.FC<Props> = ({ data, gradient = false }) => (
<div <div
className={`flex flex-col items-center justify-center gap-7 rounded-[10px] px-14 pt-10 text-center ${ className={`flex flex-col items-center justify-center gap-7 rounded-[10px] px-14 pt-10 text-center ${
gradient ? "bg-gradient-to-b from-[#2C8DFF]/50 via-brand-base to-transparent" : "" gradient ? "bg-gradient-to-b from-[#C1DDFF] via-brand-base to-transparent" : ""
}`} }`}
> >
<div className="h-44 w-full"> <div className="h-44 w-full">

View File

@ -66,10 +66,17 @@ export const UserDetails: React.FC<Props> = ({ user, setStep, setUserRole }) =>
return ( return (
<form className="flex w-full items-center justify-center" onSubmit={handleSubmit(onSubmit)}> <form className="flex w-full items-center justify-center" onSubmit={handleSubmit(onSubmit)}>
<div className="flex w-full max-w-xl flex-col gap-12"> <div className="flex w-full max-w-xl flex-col gap-7">
<div className="flex flex-col rounded-[10px] bg-brand-base shadow-md"> <div className="flex flex-col rounded-[10px] bg-brand-base shadow-md">
<div className="flex flex-col justify-between gap-3 px-10 py-7 sm:flex-row"> <div className="flex flex-col gap-2 justify-center px-7 pt-7 pb-3.5">
<div className="flex flex-col items-start justify-center gap-2.5"> <h3 className="text-base font-semibold text-brand-base">User Details</h3>
<p className="text-sm text-brand-secondary">
Enter your details as a first step to open your Plane account.
</p>
</div>
<div className="flex flex-col justify-between gap-4 px-7 py-3.5 sm:flex-row">
<div className="flex flex-col items-start justify-center gap-2.5 w-full sm:w-1/2">
<span>First name</span> <span>First name</span>
<Input <Input
name="first_name" name="first_name"
@ -81,7 +88,7 @@ export const UserDetails: React.FC<Props> = ({ user, setStep, setUserRole }) =>
error={errors.first_name} error={errors.first_name}
/> />
</div> </div>
<div className="flex flex-col items-start justify-center gap-2.5"> <div className="flex flex-col items-start justify-center gap-2.5 w-full sm:w-1/2">
<span>Last name</span> <span>Last name</span>
<Input <Input
name="last_name" name="last_name"
@ -94,7 +101,8 @@ export const UserDetails: React.FC<Props> = ({ user, setStep, setUserRole }) =>
/> />
</div> </div>
</div> </div>
<div className="flex flex-col items-start justify-center gap-2.5 border-t border-brand-base px-10 py-7">
<div className="flex flex-col items-start justify-center gap-2.5 border-t border-brand-base px-7 pt-3.5 pb-7">
<span>What is your role?</span> <span>What is your role?</span>
<div className="w-full"> <div className="w-full">
<Controller <Controller
@ -123,6 +131,7 @@ export const UserDetails: React.FC<Props> = ({ user, setStep, setUserRole }) =>
</div> </div>
</div> </div>
</div> </div>
<div className="flex w-full items-center justify-center "> <div className="flex w-full items-center justify-center ">
<PrimaryButton <PrimaryButton
type="submit" type="submit"

View File

@ -16,6 +16,7 @@ import { USER_WORKSPACE_INVITATIONS } from "constants/fetch-keys";
import { CreateWorkspaceForm } from "components/workspace"; import { CreateWorkspaceForm } from "components/workspace";
// ui // ui
import { PrimaryButton } from "components/ui"; import { PrimaryButton } from "components/ui";
import { getFirstCharacters, truncateText } from "helpers/string.helper";
type Props = { type Props = {
setStep: React.Dispatch<React.SetStateAction<number>>; setStep: React.Dispatch<React.SetStateAction<number>>;
@ -30,6 +31,7 @@ export const Workspace: React.FC<Props> = ({ setStep, setWorkspace }) => {
slug: "", slug: "",
company_size: null, company_size: null,
}); });
const [currentTab, setCurrentTab] = useState("create");
const { data: invitations, mutate } = useSWR(USER_WORKSPACE_INVITATIONS, () => const { data: invitations, mutate } = useSWR(USER_WORKSPACE_INVITATIONS, () =>
workspaceService.userWorkspaceInvitations() workspaceService.userWorkspaceInvitations()
@ -64,53 +66,72 @@ export const Workspace: React.FC<Props> = ({ setStep, setWorkspace }) => {
}); });
}; };
const currentTabValue = (tab: string | null) => {
switch (tab) {
case "join":
return 0;
case "create":
return 1;
default:
return 1;
}
};
console.log("invitations:", invitations);
return ( return (
<div className="grid min-h-[490px] w-full place-items-center"> <div className="grid w-full place-items-center">
<Tab.Group <Tab.Group
as="div" as="div"
className="flex h-full w-full max-w-xl flex-col justify-between rounded-[10px] bg-brand-base shadow-md" className="flex h-[417px] w-full max-w-xl flex-col justify-between rounded-[10px] bg-brand-base shadow-md"
defaultIndex={currentTabValue(currentTab)}
onChange={(i) => {
switch (i) {
case 0:
return setCurrentTab("join");
case 1:
return setCurrentTab("create");
default:
return setCurrentTab("create");
}
}}
> >
<Tab.List <Tab.List as="div" className="flex flex-col gap-3 px-7 pt-7 pb-3.5">
as="div" <div className="flex flex-col gap-2 justify-center">
className="text-gray-8 flex items-center justify-start gap-3 px-4 pt-4 text-sm" <h3 className="text-base font-semibold text-brand-base">Workspaces</h3>
> <p className="text-sm text-brand-secondary">
<Tab Create or join the workspace to get started with Plane.
className={({ selected }) => </p>
`rounded-3xl border px-4 py-2 outline-none ${ </div>
selected <div className="text-gray-8 flex items-center justify-start gap-3 text-sm">
? "border-brand-accent bg-brand-accent text-white" <Tab
: "border-brand-base bg-brand-surface-2 hover:bg-brand-surface-1" className={({ selected }) =>
}` `rounded-3xl border px-4 py-2 outline-none ${
} selected
> ? "border-brand-accent bg-brand-accent text-white font-medium"
New Workspace : "border-brand-base bg-brand-base hover:bg-brand-surface-2"
</Tab> }`
<Tab }
className={({ selected }) => >
`rounded-3xl border px-5 py-2 outline-none ${ Invited Workspace
selected </Tab>
? "border-brand-accent bg-brand-accent text-white" <Tab
: "border-brand-base bg-brand-surface-2 hover:bg-brand-surface-1" className={({ selected }) =>
}` `rounded-3xl border px-4 py-2 outline-none ${
} selected
> ? "border-brand-accent bg-brand-accent text-white font-medium"
Invited Workspace : "border-brand-base bg-brand-base hover:bg-brand-surface-2"
</Tab> }`
}
>
New Workspace
</Tab>
</div>
</Tab.List> </Tab.List>
<Tab.Panels as="div" className="h-full"> <Tab.Panels as="div" className="h-full">
<Tab.Panel>
<CreateWorkspaceForm
onSubmit={(res) => {
setWorkspace(res);
setStep(3);
}}
defaultValues={defaultValues}
setDefaultValues={setDefaultValues}
/>
</Tab.Panel>
<Tab.Panel className="h-full"> <Tab.Panel className="h-full">
<div className="flex h-full w-full flex-col justify-between"> <div className="flex h-full w-full flex-col">
<div className="divide-y px-4 py-7"> <div className="h-[255px] overflow-y-auto px-7">
{invitations && invitations.length > 0 ? ( {invitations && invitations.length > 0 ? (
invitations.map((invitation) => ( invitations.map((invitation) => (
<div key={invitation.id}> <div key={invitation.id}>
@ -129,34 +150,62 @@ export const Workspace: React.FC<Props> = ({ setStep, setWorkspace }) => {
alt={invitation.workspace.name} alt={invitation.workspace.name}
/> />
) : ( ) : (
<span className="flex h-full w-full items-center justify-center rounded bg-gray-700 p-4 uppercase text-white"> <span className="flex h-full w-full items-center justify-center rounded-xl bg-gray-700 p-4 uppercase text-white">
{invitation.workspace.name.charAt(0)} {getFirstCharacters(invitation.workspace.name)}
</span> </span>
)} )}
</span> </span>
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-sm font-medium">{invitation.workspace.name}</div> <div className="text-sm font-medium">
{truncateText(invitation.workspace.name, 30)}
</div>
<p className="text-sm text-brand-secondary"> <p className="text-sm text-brand-secondary">
Invited by {invitation.workspace.owner.first_name} Invited by {invitation.workspace.owner.first_name}
</p> </p>
</div> </div>
<div className="flex-shrink-0 self-center"> <div className="flex-shrink-0 self-center">
<input <button
id={invitation.id} className={`${
aria-describedby="workspaces" invitationsRespond.includes(invitation.id)
name={invitation.id} ? "bg-brand-surface-2 text-brand-secondary"
checked={invitationsRespond.includes(invitation.id)} : "bg-brand-accent text-white"
value={invitation.workspace.name} } text-sm px-4 py-2 border border-brand-base rounded-3xl`}
onChange={(e) => { onClick={(e) => {
handleInvitation( handleInvitation(
invitation, invitation,
invitationsRespond.includes(invitation.id) ? "withdraw" : "accepted" invitationsRespond.includes(invitation.id) ? "withdraw" : "accepted"
); );
}} }}
type="checkbox" >
className="h-4 w-4 rounded border-brand-base text-brand-accent focus:ring-brand-accent" {invitationsRespond.includes(invitation.id)
/> ? "Invitation Accepted"
: "Accept Invitation"}
</button>
{/* <input
id={invitation.id}
aria-describedby="workspaces"
name={invitation.id}
value={
invitationsRespond.includes(invitation.id)
? "Invitation Accepted"
: "Accept Invitation"
}
onClick={(e) => {
handleInvitation(
invitation,
invitationsRespond.includes(invitation.id) ? "withdraw" : "accepted"
);
}}
type="button"
className={`${
invitationsRespond.includes(invitation.id)
? "bg-brand-surface-2 text-brand-secondary"
: "bg-brand-accent text-white"
} text-sm px-4 py-2 border border-brand-base rounded-3xl`}
// className="h-4 w-4 rounded border-brand-base text-brand-accent focus:ring-brand-accent"
/> */}
</div> </div>
</label> </label>
</div> </div>
@ -167,7 +216,7 @@ export const Workspace: React.FC<Props> = ({ setStep, setWorkspace }) => {
</div> </div>
)} )}
</div> </div>
<div className="flex w-full items-center justify-center rounded-b-[10px] py-7"> <div className="flex w-full items-center justify-center rounded-b-[10px] pt-10">
<PrimaryButton <PrimaryButton
type="submit" type="submit"
className="w-1/2 text-center" className="w-1/2 text-center"
@ -180,6 +229,16 @@ export const Workspace: React.FC<Props> = ({ setStep, setWorkspace }) => {
</div> </div>
</div> </div>
</Tab.Panel> </Tab.Panel>
<Tab.Panel className="h-full">
<CreateWorkspaceForm
onSubmit={(res) => {
setWorkspace(res);
setStep(3);
}}
defaultValues={defaultValues}
setDefaultValues={setDefaultValues}
/>
</Tab.Panel>
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>
</div> </div>

View File

@ -73,9 +73,12 @@ const SendProjectInvitationModal: React.FC<Props> = ({ isOpen, setIsOpen, member
.inviteProject(workspaceSlug as string, projectId as string, formData) .inviteProject(workspaceSlug as string, projectId as string, formData)
.then((response) => { .then((response) => {
setIsOpen(false); setIsOpen(false);
mutate( mutate<any[]>(
PROJECT_INVITATIONS, PROJECT_INVITATIONS,
(prevData: any[]) => [{ ...formData, ...response }, ...(prevData ?? [])], (prevData) => {
if (!prevData) return prevData;
return [{ ...formData, ...response }, ...(prevData ?? [])];
},
false false
); );
setToastAlert({ setToastAlert({

View File

@ -0,0 +1,12 @@
import React from "react";
type Props = {
iconName: string;
className?: string;
};
export const Icon: React.FC<Props> = ({ iconName, className = "" }) => (
<span className={`material-symbols-rounded text-lg leading-5 font-light ${className}`}>
{iconName}
</span>
);

View File

@ -25,3 +25,4 @@ export * from "./markdown-to-component";
export * from "./product-updates-modal"; export * from "./product-updates-modal";
export * from "./integration-and-import-export-banner"; export * from "./integration-and-import-export-banner";
export * from "./range-datepicker"; export * from "./range-datepicker";
export * from "./icon";

View File

@ -99,110 +99,105 @@ export const CreateWorkspaceForm: React.FC<Props> = ({
); );
return ( return (
<form <form className="flex h-full w-full flex-col" onSubmit={handleSubmit(handleCreateWorkspace)}>
className="flex w-full items-center justify-center" <div className="divide-y h-[255px]">
onSubmit={handleSubmit(handleCreateWorkspace)} <div className="flex flex-col justify-between gap-3.5 px-7 pb-3.5">
> <div className="flex flex-col items-start justify-center gap-2.5">
<div className="flex w-full max-w-xl flex-col"> <span className="text-sm">Workspace name</span>
<div className="flex flex-col rounded-[10px] bg-brand-base"> <Input
<div className="flex flex-col justify-between gap-3 px-4 py-7"> name="name"
<div className="flex flex-col items-start justify-center gap-2.5"> register={register}
<span className="text-sm">Workspace name</span> autoComplete="off"
onChange={(e) =>
setValue("slug", e.target.value.toLocaleLowerCase().trim().replace(/ /g, "-"))
}
validations={{
required: "Workspace name is required",
validate: (value) =>
/^[\w\s-]*$/.test(value) ||
`Name can only contain (" "), ( - ), ( _ ) & Alphanumeric characters.`,
}}
placeholder="e.g. My Workspace"
className="placeholder:text-brand-secondary"
error={errors.name}
/>
</div>
<div className="flex flex-col items-start justify-center gap-2.5">
<span className="text-sm">Workspace URL</span>
<div className="flex w-full items-center rounded-md border border-brand-base px-3">
<span className="whitespace-nowrap text-sm text-brand-secondary">
{typeof window !== "undefined" && window.location.origin}/
</span>
<Input <Input
name="name" mode="trueTransparent"
register={register}
autoComplete="off" autoComplete="off"
onChange={(e) => name="slug"
setValue("slug", e.target.value.toLocaleLowerCase().trim().replace(/ /g, "-")) register={register}
} className="block w-full rounded-md bg-transparent py-2 !px-0 text-sm"
validations={{ validations={{
required: "Workspace name is required", required: "Workspace URL is required",
validate: (value) =>
/^[\w\s-]*$/.test(value) ||
`Name can only contain (" "), ( - ), ( _ ) & Alphanumeric characters.`,
}} }}
placeholder="e.g. My Workspace" onChange={(e) =>
className="placeholder:text-brand-secondary" /^[a-zA-Z0-9_-]+$/.test(e.target.value)
error={errors.name} ? setInvalidSlug(false)
: setInvalidSlug(true)
}
/> />
</div> </div>
<div className="flex flex-col items-start justify-center gap-2.5"> {slugError && (
<span className="text-sm">Workspace URL</span> <span className="-mt-3 text-sm text-red-500">Workspace URL is already taken!</span>
<div className="flex w-full items-center rounded-md border border-brand-base px-3"> )}
<span className="whitespace-nowrap text-sm text-brand-secondary"> {invalidSlug && (
{typeof window !== "undefined" && window.location.origin}/ <span className="text-sm text-red-500">{`URL can only contain ( - ), ( _ ) & Alphanumeric characters.`}</span>
</span> )}
<Input
mode="trueTransparent"
autoComplete="off"
name="slug"
register={register}
className="block w-full rounded-md bg-transparent py-2 !px-0 text-sm"
validations={{
required: "Workspace URL is required",
}}
onChange={(e) =>
/^[a-zA-Z0-9_-]+$/.test(e.target.value)
? setInvalidSlug(false)
: setInvalidSlug(true)
}
/>
</div>
{slugError && (
<span className="-mt-3 text-sm text-red-500">Workspace URL is already taken!</span>
)}
{invalidSlug && (
<span className="text-sm text-red-500">{`URL can only contain ( - ), ( _ ) & Alphanumeric characters.`}</span>
)}
</div>
</div>
<div className="flex flex-col items-start justify-center gap-2.5 border-t border-brand-base px-4 py-7">
<span className="text-sm">How large is your company?</span>
<div className="w-full">
<Controller
name="company_size"
control={control}
rules={{ required: "This field is required" }}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={
value ? (
value.toString()
) : (
<span className="text-brand-secondary">Select company size</span>
)
}
input
width="w-full"
>
{COMPANY_SIZE?.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
{errors.company_size && (
<span className="text-sm text-red-500">{errors.company_size.message}</span>
)}
</div>
</div>
<div className="flex w-full items-center justify-center rounded-b-[10px] py-7">
<PrimaryButton
type="submit"
className="flex w-1/2 items-center justify-center text-center"
size="md"
disabled={isSubmitting}
>
{isSubmitting ? "Creating..." : "Create Workspace"}
</PrimaryButton>
</div> </div>
</div> </div>
<div className="flex flex-col items-start justify-center gap-2.5 border-t border-brand-base px-7 pt-3.5 ">
<span className="text-sm">How large is your company?</span>
<div className="w-full">
<Controller
name="company_size"
control={control}
rules={{ required: "This field is required" }}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={
value ? (
value.toString()
) : (
<span className="text-brand-secondary">Select company size</span>
)
}
input
width="w-full"
>
{COMPANY_SIZE?.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
{errors.company_size && (
<span className="text-sm text-red-500">{errors.company_size.message}</span>
)}
</div>
</div>
</div>
<div className="flex w-full items-center justify-center rounded-b-[10px] pt-10">
<PrimaryButton
type="submit"
className="flex w-1/2 items-center justify-center text-center"
size="md"
disabled={isSubmitting}
>
{isSubmitting ? "Creating..." : "Create Workspace"}
</PrimaryButton>
</div> </div>
</form> </form>
); );

View File

@ -67,17 +67,19 @@ export const WorkspaceSidebarDropdown = () => {
}; };
const handleSignOut = async () => { const handleSignOut = async () => {
router.push("/signin").then(() => { await authenticationService
mutateUser(); .signOut()
}); .then(() => {
mutateUser(undefined);
await authenticationService.signOut().catch(() => router.push("/");
setToastAlert({
type: "error",
title: "Error!",
message: "Failed to sign out. Please try again.",
}) })
); .catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "Failed to sign out. Please try again.",
})
);
}; };
return ( return (
@ -137,8 +139,8 @@ export const WorkspaceSidebarDropdown = () => {
border border-brand-base bg-brand-surface-2 shadow-lg focus:outline-none" border border-brand-base bg-brand-surface-2 shadow-lg focus:outline-none"
> >
<div className="flex flex-col items-start justify-start gap-3 p-3"> <div className="flex flex-col items-start justify-start gap-3 p-3">
<div className="text-sm text-brand-secondary">{user?.email}</div> <div className="text-sm text-gray-500">{user?.email}</div>
<span className="text-sm font-semibold text-brand-secondary">Workspace</span> <span className="text-sm font-semibold text-gray-500">Workspace</span>
{workspaces ? ( {workspaces ? (
<div className="flex h-full w-full flex-col items-start justify-start gap-3.5"> <div className="flex h-full w-full flex-col items-start justify-start gap-3.5">
{workspaces.length > 0 ? ( {workspaces.length > 0 ? (

View File

@ -1,4 +1,5 @@
// next // next
import { getFirstCharacters, truncateText } from "helpers/string.helper";
import Image from "next/image"; import Image from "next/image";
// react // react
import { useState } from "react"; import { useState } from "react";
@ -22,9 +23,7 @@ const SingleInvitation: React.FC<Props> = ({
<> <>
<li> <li>
<label <label
className={`group relative flex cursor-pointer items-start space-x-3 border-2 border-transparent px-4 py-4 ${ className={`group relative flex cursor-pointer items-start space-x-3 border-2 border-transparent py-4`}
isChecked ? "rounded-lg border-theme" : ""
}`}
htmlFor={invitation.id} htmlFor={invitation.id}
> >
<div className="flex-shrink-0"> <div className="flex-shrink-0">
@ -38,35 +37,36 @@ const SingleInvitation: React.FC<Props> = ({
alt={invitation.workspace.name} alt={invitation.workspace.name}
/> />
) : ( ) : (
<span className="flex h-full w-full items-center justify-center rounded bg-gray-700 p-4 uppercase text-white"> <span className="flex h-full w-full items-center justify-center rounded-xl bg-gray-700 p-4 uppercase text-white">
{invitation.workspace.name.charAt(0)} {getFirstCharacters(invitation.workspace.name)}
</span> </span>
)} )}
</span> </span>
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-sm font-medium text-brand-base">{invitation.workspace.name}</div> <div className="text-sm font-medium">{truncateText(invitation.workspace.name, 30)}</div>
<p className="text-sm text-brand-secondary"> <p className="text-sm text-brand-secondary">
Invited by {invitation.workspace.owner.first_name} Invited by {invitation.workspace.owner.first_name}
</p> </p>
</div> </div>
<div className="flex-shrink-0 self-center"> <div className="flex-shrink-0 self-center">
<input <button
id={invitation.id} className={`${
aria-describedby="workspaces" invitationsRespond.includes(invitation.id)
name={invitation.id} ? "bg-brand-surface-2 text-brand-secondary"
checked={invitationsRespond.includes(invitation.id)} : "bg-brand-accent text-white"
value={invitation.workspace.name} } text-sm px-4 py-2 border border-brand-base rounded-3xl`}
onChange={(e) => { onClick={(e) => {
handleInvitation( handleInvitation(
invitation, invitation,
invitationsRespond.includes(invitation.id) ? "withdraw" : "accepted" invitationsRespond.includes(invitation.id) ? "withdraw" : "accepted"
); );
setIsChecked(e.target.checked);
}} }}
type="checkbox" >
className="h-4 w-4 rounded border-brand-base text-brand-accent focus:ring-indigo-500" {invitationsRespond.includes(invitation.id)
/> ? "Invitation Accepted"
: "Accept Invitation"}
</button>
</div> </div>
</label> </label>
</li> </li>

View File

@ -109,3 +109,12 @@ export const generateRandomColor = (string: string): string => {
return randomColor; return randomColor;
}; };
export const getFirstCharacters = (str: string) => {
const words = str.trim().split(" ");
if (words.length === 1) {
return words[0].charAt(0);
} else {
return words[0].charAt(0) + words[1].charAt(0);
}
};

View File

@ -0,0 +1,107 @@
import { useEffect, useState } from "react";
// next imports
import { useRouter } from "next/router";
// swr
import useSWR from "swr";
// keys
import { CURRENT_USER } from "constants/fetch-keys";
// services
import userService from "services/user.service";
import workspaceService from "services/workspace.service";
// types
import type { IWorkspace, ICurrentUserResponse } from "types";
const useUserAuth = (routeAuth: "sign-in" | "onboarding" | "admin" | null = "admin") => {
const router = useRouter();
const [isRouteAccess, setIsRouteAccess] = useState(true);
const {
data: user,
isLoading,
error,
mutate,
} = useSWR<ICurrentUserResponse>(CURRENT_USER, () => userService.currentUser());
useEffect(() => {
const handleWorkSpaceRedirection = async () => {
workspaceService.userWorkspaces().then(async (userWorkspaces) => {
const lastActiveWorkspace = userWorkspaces.find(
(workspace: IWorkspace) => workspace.id === user?.last_workspace_id
);
if (lastActiveWorkspace) {
router.push(`/${lastActiveWorkspace.slug}`);
return;
} else if (userWorkspaces.length > 0) {
router.push(`/${userWorkspaces[0].slug}`);
return;
} else {
const invitations = await workspaceService.userWorkspaceInvitations();
if (invitations.length > 0) {
router.push(`/invitations`);
return;
} else {
router.push(`/create-workspace`);
return;
}
}
});
};
const handleUserRouteAuthentication = async () => {
console.log("user", user);
if (user && user.is_active) {
if (routeAuth === "sign-in") {
if (user.is_onboarded) handleWorkSpaceRedirection();
else {
router.push("/onboarding");
return;
}
} else if (routeAuth === "onboarding") {
if (user.is_onboarded) handleWorkSpaceRedirection();
else {
setIsRouteAccess(() => false);
return;
}
} else {
if (!user.is_onboarded) {
router.push("/onboarding");
return;
} else {
setIsRouteAccess(() => false);
return;
}
}
} else {
// user is not active and we can redirect to no access page
// router.push("/no-access");
// remove token
return;
}
};
if (!isLoading) {
setIsRouteAccess(() => true);
if (user) handleUserRouteAuthentication();
else {
if (routeAuth === "sign-in") {
setIsRouteAccess(() => false);
return;
} else {
router.push("/");
return;
}
}
}
}, [user, isLoading, routeAuth, router]);
return {
isLoading: isRouteAccess,
user: error ? undefined : user,
mutateUser: mutate,
assignedIssuesLength: user?.assigned_issues ?? 0,
workspaceInvitesLength: user?.workspace_invites ?? 0,
};
};
export default useUserAuth;

View File

@ -1,13 +1,55 @@
import { useContext } from "react"; import { useEffect } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import userService from "services/user.service";
// constants
import { CURRENT_USER } from "constants/fetch-keys";
// types
import type { ICurrentUserResponse, IUser } from "types";
// context export default function useUser({ redirectTo = "", redirectIfFound = false, options = {} } = {}) {
import { UserContext } from "contexts/user.context"; const router = useRouter();
// API to fetch user information
const { data, isLoading, error, mutate } = useSWR<ICurrentUserResponse>(
CURRENT_USER,
() => userService.currentUser(),
options
);
const useUser = () => { const user = error ? undefined : data;
// context // console.log("useUser", user);
const contextData = useContext(UserContext);
return { ...contextData }; useEffect(() => {
}; // if no redirect needed, just return (example: already on /dashboard)
// if user data not yet there (fetch in progress, logged in or not) then don't do anything yet
if (!redirectTo || !user) return;
export default useUser; if (
// If redirectTo is set, redirect if the user was not found.
(redirectTo && !redirectIfFound) ||
// If redirectIfFound is also set, redirect if the user was found
(redirectIfFound && user)
) {
router.push(redirectTo);
return;
// const nextLocation = router.asPath.split("?next=")[1];
// if (nextLocation) {
// router.push(nextLocation as string);
// return;
// } else {
// router.push("/");
// return;
// }
}
}, [user, redirectIfFound, redirectTo, router]);
return {
user,
isUserLoading: isLoading,
mutateUser: mutate,
userError: error,
assignedIssuesLength: user?.assigned_issues ?? 0,
workspaceInvitesLength: user?.workspace_invites ?? 0,
};
}

View File

@ -32,7 +32,7 @@ export const UserAuthorizationLayout: React.FC<Props> = ({ children }) => {
if (error) { if (error) {
const redirectTo = router.asPath; const redirectTo = router.asPath;
router.push(`/signin?next=${redirectTo}`); router.push(`/?next=${redirectTo}`);
return null; return null;
} }

View File

@ -104,7 +104,7 @@ export const homePageRedirect = async (cookie?: string) => {
if (!user) if (!user)
return { return {
redirect: { redirect: {
destination: "/signin", destination: "/",
permanent: false, permanent: false,
}, },
}; };

View File

@ -48,7 +48,7 @@
"react-markdown": "^8.0.7", "react-markdown": "^8.0.7",
"recharts": "^2.3.2", "recharts": "^2.3.2",
"remirror": "^2.0.23", "remirror": "^2.0.23",
"swr": "^1.3.0", "swr": "^2.1.3",
"tlds": "^1.238.0", "tlds": "^1.238.0",
"uuid": "^9.0.0" "uuid": "^9.0.0"
}, },

View File

@ -8,7 +8,7 @@ import { Controller, useForm } from "react-hook-form";
import fileService from "services/file.service"; import fileService from "services/file.service";
import userService from "services/user.service"; import userService from "services/user.service";
// hooks // hooks
import useUser from "hooks/use-user"; import useUserAuth from "hooks/use-user-auth";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// layouts // layouts
import { WorkspaceAuthorizationLayout } from "layouts/auth-layout"; import { WorkspaceAuthorizationLayout } from "layouts/auth-layout";
@ -50,7 +50,7 @@ const Profile: NextPage = () => {
} = useForm<IUser>({ defaultValues }); } = useForm<IUser>({ defaultValues });
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { user: myProfile, mutateUser } = useUser(); const { user: myProfile, mutateUser } = useUserAuth();
useEffect(() => { useEffect(() => {
reset({ ...defaultValues, ...myProfile }); reset({ ...defaultValues, ...myProfile });

View File

@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
// hooks // hooks
import useUser from "hooks/use-user"; import useUserAuth from "hooks/use-user-auth";
// layouts // layouts
import { WorkspaceAuthorizationLayout } from "layouts/auth-layout"; import { WorkspaceAuthorizationLayout } from "layouts/auth-layout";
import SettingsNavbar from "layouts/settings-navbar"; import SettingsNavbar from "layouts/settings-navbar";
@ -15,7 +15,7 @@ import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
import { ICustomTheme } from "types"; import { ICustomTheme } from "types";
const ProfilePreferences = () => { const ProfilePreferences = () => {
const { user: myProfile } = useUser(); const { user: myProfile } = useUserAuth();
const { theme } = useTheme(); const { theme } = useTheme();
const [customThemeSelectorOptions, setCustomThemeSelectorOptions] = useState(false); const [customThemeSelectorOptions, setCustomThemeSelectorOptions] = useState(false);
const [preLoadedData, setPreLoadedData] = useState<ICustomTheme | null>(null); const [preLoadedData, setPreLoadedData] = useState<ICustomTheme | null>(null);

View File

@ -78,12 +78,15 @@ const IssueDetailsPage: NextPage = () => {
async (formData: Partial<IIssue>) => { async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueId) return; if (!workspaceSlug || !projectId || !issueId) return;
mutate( mutate<IIssue>(
ISSUE_DETAILS(issueId as string), ISSUE_DETAILS(issueId as string),
(prevData: IIssue) => ({ (prevData) => {
...prevData, if (!prevData) return prevData;
...formData, return {
}), ...prevData,
...formData,
};
},
false false
); );

View File

@ -44,31 +44,14 @@ Router.events.on("routeChangeComplete", NProgress.done);
function MyApp({ Component, pageProps }: AppProps) { function MyApp({ Component, pageProps }: AppProps) {
return ( return (
<ThemeProvider themes={THEMES} defaultTheme="light"> // <UserProvider>
<UserProvider> <ToastContextProvider>
<ToastContextProvider> <ThemeContextProvider>
<ThemeContextProvider> <CrispWithNoSSR />
<CrispWithNoSSR />{" "} <Component {...pageProps} />
<Head> </ThemeContextProvider>
<title>{SITE_TITLE}</title> </ToastContextProvider>
<meta property="og:site_name" content={SITE_NAME} /> // </UserProvider>
<meta property="og:title" content={SITE_TITLE} />
<meta property="og:url" content={SITE_URL} />
<meta name="description" content={SITE_DESCRIPTION} />
<meta property="og:description" content={SITE_DESCRIPTION} />
<meta name="keywords" content={SITE_KEYWORDS} />
<meta name="twitter:site" content={`@${TWITTER_USER_NAME}`} />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest.json" />
<link rel="shortcut icon" href="/favicon/favicon.ico" />
</Head>
<Component {...pageProps} />
</ThemeContextProvider>
</ToastContextProvider>
</UserProvider>
</ThemeProvider>
); );
} }

View File

@ -26,7 +26,7 @@ const CustomErrorComponent = () => {
message: "Failed to sign out. Please try again.", message: "Failed to sign out. Please try again.",
}) })
) )
.finally(() => router.push("/signin")); .finally(() => router.push("/"));
}; };
return ( return (

View File

@ -2,7 +2,10 @@ import React from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import Image from "next/image"; import Image from "next/image";
// hooks
import useUser from "hooks/use-user";
// components
import { OnboardingLogo } from "components/onboarding";
// layouts // layouts
import DefaultLayout from "layouts/default-layout"; import DefaultLayout from "layouts/default-layout";
import { UserAuthorizationLayout } from "layouts/auth-layout/user-authorization-wrapper"; import { UserAuthorizationLayout } from "layouts/auth-layout/user-authorization-wrapper";
@ -21,19 +24,36 @@ const CreateWorkspace: NextPage = () => {
company_size: null, company_size: null,
}; };
const { user } = useUser();
return ( return (
<UserAuthorizationLayout> <UserAuthorizationLayout>
<DefaultLayout> <DefaultLayout>
<div className="grid h-full place-items-center p-5"> <div className="relative grid h-full place-items-center p-5">
<div className="w-full space-y-4"> <div className="h-full flex flex-col items-center justify-center w-full py-4">
<div className="mb-8 text-center"> <div className="mb-7 flex items-center justify-center text-center">
<Image src={Logo} height="50" alt="Plane Logo" /> <OnboardingLogo className="h-12 w-48 fill-current text-brand-base" />
</div> </div>
<CreateWorkspaceForm
defaultValues={defaultValues} <div className="flex h-[366px] w-full max-w-xl flex-col justify-between rounded-[10px] bg-brand-base shadow-md">
setDefaultValues={() => {}} <div className="flex items-center justify-start gap-3 px-7 pt-7 pb-3.5 text-gray-8 text-sm">
onSubmit={(res) => router.push(`/${res.slug}`)} <div className="flex flex-col gap-2 justify-center ">
/> <h3 className="text-base font-semibold text-brand-base">Create Workspace</h3>
<p className="text-sm text-brand-secondary">
Create or join the workspace to get started with Plane.
</p>
</div>
</div>
<CreateWorkspaceForm
defaultValues={defaultValues}
setDefaultValues={() => {}}
onSubmit={(res) => router.push(`/${res.slug}`)}
/>
</div>
</div>
<div className="absolute flex flex-col gap-1 justify-center items-start left-5 top-5">
<span className="text-xs text-brand-secondary">Logged in:</span>
<span className="text-sm text-brand-base">{user?.email}</span>
</div> </div>
</div> </div>
</DefaultLayout> </DefaultLayout>

View File

@ -1,66 +1,116 @@
import { useEffect } from "react"; import React from "react";
// next imports
import Router from "next/router"; import Image from "next/image";
// next types
import type { NextPage } from "next";
// layouts
import DefaultLayout from "layouts/default-layout";
// hooks
import useUserAuth from "hooks/use-user-auth";
import useToast from "hooks/use-toast";
// services // services
import userService from "services/user.service"; import authenticationService from "services/authentication.service";
import workspaceService from "services/workspace.service"; // social auth buttons
import {
GoogleLoginButton,
GithubLoginButton,
EmailCodeForm,
EmailPasswordForm,
} from "components/account";
// ui // ui
import { Spinner } from "components/ui"; import { Spinner } from "components/ui";
// types // icons
import type { NextPage } from "next"; import Logo from "public/logo.png";
const redirectUserTo = async () => { const HomePage: NextPage = () => {
const user = await userService const { user, isLoading, mutateUser } = useUserAuth("sign-in");
.currentUser()
.then((res) => res)
.catch(() => {
Router.push("/signin");
return;
});
if (!user) { const { setToastAlert } = useToast();
Router.push("/signin");
return;
} else if (!user.user.is_onboarded) {
Router.push("/onboarding");
return;
} else {
const userWorkspaces = await workspaceService.userWorkspaces();
const lastActiveWorkspace = userWorkspaces.find( const handleGoogleSignIn = async ({ clientId, credential }: any) => {
(workspace) => workspace.id === user.user.last_workspace_id try {
); if (clientId && credential) {
mutateUser(
if (lastActiveWorkspace) { await authenticationService.socialAuth({
Router.push(`/${lastActiveWorkspace.slug}`); medium: "google",
return; credential,
} else if (userWorkspaces.length > 0) { clientId,
Router.push(`/${userWorkspaces[0].slug}`); })
return; );
} else {
const invitations = await workspaceService.userWorkspaceInvitations();
if (invitations.length > 0) {
Router.push(`/invitations`);
return;
} else { } else {
Router.push(`/create-workspace`); throw Error("Cant find credentials");
return;
} }
} catch (error) {
console.log(error);
setToastAlert({
title: "Error signing in!",
type: "error",
message: "Something went wrong. Please try again later or contact the support team.",
});
} }
} };
};
const Home: NextPage = () => { const handleGithubSignIn = async (credential: string) => {
useEffect(() => { try {
redirectUserTo(); if (process.env.NEXT_PUBLIC_GITHUB_ID && credential) {
}, []); mutateUser(
await authenticationService.socialAuth({
medium: "github",
credential,
clientId: process.env.NEXT_PUBLIC_GITHUB_ID,
})
);
} else {
throw Error("Cant find credentials");
}
} catch (error) {
console.log(error);
setToastAlert({
title: "Error signing in!",
type: "error",
message: "Something went wrong. Please try again later or contact the support team.",
});
}
};
return ( return (
<div className="grid h-screen place-items-center"> <DefaultLayout>
<Spinner /> {isLoading ? (
</div> <div className="grid h-screen place-items-center">
<Spinner />
</div>
) : (
<div className="flex h-screen w-full items-center justify-center overflow-auto bg-gray-50">
<div className="flex min-h-full w-full flex-col justify-center py-12 px-6 lg:px-8">
<div className="flex flex-col gap-10 sm:mx-auto sm:w-full sm:max-w-md">
<div className="flex flex-col items-center justify-center gap-10">
<Image src={Logo} height={80} width={80} alt="Plane Web Logo" />
<div className="text-center text-xl font-medium text-black">
Sign In to your Plane Account
</div>
</div>
<div className="flex flex-col rounded-[10px] bg-white shadow-md">
{parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? (
<>
<EmailCodeForm />
<div className="flex flex-col gap-3 py-5 px-5 border-t items-center justify-center border-gray-300 ">
<GoogleLoginButton handleSignIn={handleGoogleSignIn} />
<GithubLoginButton handleSignIn={handleGithubSignIn} />
</div>
</>
) : (
<>
<EmailPasswordForm />
</>
)}
</div>
</div>
</div>
</div>
)}
</DefaultLayout>
); );
}; };
export default Home; export default HomePage;

View File

@ -15,6 +15,7 @@ import DefaultLayout from "layouts/default-layout";
import { UserAuthorizationLayout } from "layouts/auth-layout/user-authorization-wrapper"; import { UserAuthorizationLayout } from "layouts/auth-layout/user-authorization-wrapper";
// components // components
import SingleInvitation from "components/workspace/single-invitation"; import SingleInvitation from "components/workspace/single-invitation";
import { OnboardingLogo } from "components/onboarding";
// ui // ui
import { Spinner, EmptySpace, EmptySpaceItem, SecondaryButton, PrimaryButton } from "components/ui"; import { Spinner, EmptySpace, EmptySpaceItem, SecondaryButton, PrimaryButton } from "components/ui";
// icons // icons
@ -81,86 +82,93 @@ const OnBoard: NextPage = () => {
return ( return (
<UserAuthorizationLayout> <UserAuthorizationLayout>
<DefaultLayout> <DefaultLayout>
<div className="flex min-h-full flex-col items-center justify-center p-4 sm:p-0"> <div className="relative grid h-full place-items-center p-5">
{user && ( <div className="h-full flex flex-col items-center justify-center w-full py-4">
<div className="mb-10 w-96 rounded-lg bg-brand-accent/20 p-2 text-brand-accent"> <div className="mb-7 flex items-center justify-center text-center">
<p className="text-center text-sm">logged in as {user.email}</p> <OnboardingLogo className="h-12 w-48 fill-current text-brand-base" />
</div> </div>
)}
<div className="w-full rounded-lg p-8 md:w-2/3 lg:w-1/3"> <div className="flex h-[436px] w-full max-w-xl rounded-[10px] p-7 bg-brand-base shadow-md">
{invitations && workspaces ? ( {invitations && workspaces ? (
invitations.length > 0 ? ( invitations.length > 0 ? (
<div> <div className="flex w-full flex-col gap-3 justify-between">
<h2 className="text-lg font-medium">Workspace Invitations</h2> <div className="flex flex-col gap-2 justify-center ">
<p className="mt-1 text-sm text-brand-secondary"> <h3 className="text-base font-semibold text-brand-base">
Select invites that you want to accept. Workspace Invitations
</p> </h3>
<ul <p className="text-sm text-brand-secondary">
role="list" Create or join the workspace to get started with Plane.
className="mt-6 divide-y divide-brand-base border-t border-b border-brand-base" </p>
> </div>
{invitations.map((invitation) => (
<SingleInvitation <ul role="list" className="h-[255px] w-full overflow-y-auto">
key={invitation.id} {invitations.map((invitation) => (
invitation={invitation} <SingleInvitation
invitationsRespond={invitationsRespond} key={invitation.id}
handleInvitation={handleInvitation} invitation={invitation}
/> invitationsRespond={invitationsRespond}
))} handleInvitation={handleInvitation}
</ul> />
<div className="mt-6 flex items-center gap-2"> ))}
<Link href="/"> </ul>
<a className="w-full">
<SecondaryButton className="w-full">Go Home</SecondaryButton> <div className="flex items-center gap-3">
</a> <Link href="/">
</Link> <a className="w-full">
<PrimaryButton className="w-full" onClick={submitInvitations}> <SecondaryButton className="w-full">Go Home</SecondaryButton>
Accept and Continue </a>
</PrimaryButton> </Link>
<PrimaryButton className="w-full" onClick={submitInvitations}>
Accept and Continue
</PrimaryButton>
</div>
</div> </div>
</div> ) : workspaces && workspaces.length > 0 ? (
) : workspaces && workspaces.length > 0 ? ( <div className="flex flex-col gap-y-3">
<div className="flex flex-col gap-y-3"> <h2 className="mb-4 text-xl font-medium">Your workspaces</h2>
<h2 className="mb-4 text-xl font-medium">Your workspaces</h2> {workspaces.map((workspace) => (
{workspaces.map((workspace) => ( <Link key={workspace.id} href={workspace.slug}>
<Link key={workspace.id} href={workspace.slug}> <a>
<a> <div className="mb-2 flex items-center justify-between rounded border border-brand-base px-4 py-2">
<div className="mb-2 flex items-center justify-between rounded border border-brand-base px-4 py-2"> <div className="flex items-center gap-x-2 text-sm">
<div className="flex items-center gap-x-2 text-sm"> <CubeIcon className="h-5 w-5 text-brand-secondary" />
<CubeIcon className="h-5 w-5 text-brand-secondary" /> {workspace.name}
{workspace.name} </div>
<div className="flex items-center gap-x-2 text-xs text-brand-secondary">
{workspace.owner.first_name}
</div>
</div> </div>
<div className="flex items-center gap-x-2 text-xs text-brand-secondary"> </a>
{workspace.owner.first_name} </Link>
</div> ))}
</div> </div>
</a> ) : (
</Link> invitations.length === 0 &&
))} workspaces.length === 0 && (
</div> <EmptySpace
) : ( title="You don't have any workspaces yet"
invitations.length === 0 && description="Your workspace is where you'll create projects, collaborate on your issues, and organize different streams of work in your Plane account."
workspaces.length === 0 && ( >
<EmptySpace <EmptySpaceItem
title="You don't have any workspaces yet" Icon={PlusIcon}
description="Your workspace is where you'll create projects, collaborate on your issues, and organize different streams of work in your Plane account." title={"Create your Workspace"}
> action={() => {
<EmptySpaceItem router.push("/create-workspace");
Icon={PlusIcon} }}
title={"Create your Workspace"} />
action={() => { </EmptySpace>
router.push("/create-workspace"); )
}}
/>
</EmptySpace>
) )
) ) : (
) : ( <div className="flex h-full w-full items-center justify-center">
<div className="flex h-full w-full items-center justify-center"> <Spinner />
<Spinner /> </div>
</div> )}
)} </div>
</div>
<div className="absolute flex flex-col gap-1 justify-center items-start left-5 top-5">
<span className="text-xs text-brand-secondary">Logged in:</span>
<span className="text-sm text-brand-base">{user?.email}</span>
</div> </div>
</div> </div>
</DefaultLayout> </DefaultLayout>

View File

@ -1,14 +1,15 @@
import { useState } from "react"; import { useState } from "react";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/router"; import Router, { useRouter } from "next/router";
import { mutate } from "swr"; import { mutate } from "swr";
// services // services
import userService from "services/user.service"; import userService from "services/user.service";
import workspaceService from "services/workspace.service";
// hooks // hooks
import useUser from "hooks/use-user"; import useUserAuth from "hooks/use-user-auth";
// layouts // layouts
import DefaultLayout from "layouts/default-layout"; import DefaultLayout from "layouts/default-layout";
import { UserAuthorizationLayout } from "layouts/auth-layout/user-authorization-wrapper"; import { UserAuthorizationLayout } from "layouts/auth-layout/user-authorization-wrapper";
@ -38,15 +39,17 @@ const Onboarding: NextPage = () => {
const router = useRouter(); const router = useRouter();
const { user } = useUser(); const { user } = useUserAuth("onboarding");
console.log("user", user);
return ( return (
<UserAuthorizationLayout> <UserAuthorizationLayout>
<DefaultLayout> <DefaultLayout>
<div className="grid h-full place-items-center p-5"> <div className="relative grid h-full place-items-center p-5">
{step <= 3 ? ( {step <= 3 ? (
<div className="w-full"> <div className="h-full flex flex-col justify-center w-full py-4">
<div className="mb-8 flex items-center justify-center text-center"> <div className="mb-7 flex items-center justify-center text-center">
<OnboardingLogo className="h-12 w-48 fill-current text-brand-base" /> <OnboardingLogo className="h-12 w-48 fill-current text-brand-base" />
</div> </div>
{step === 1 ? ( {step === 1 ? (
@ -59,7 +62,7 @@ const Onboarding: NextPage = () => {
</div> </div>
) : ( ) : (
<div className="flex w-full max-w-2xl flex-col gap-12"> <div className="flex w-full max-w-2xl flex-col gap-12">
<div className="flex flex-col items-center justify-center gap-7 rounded-[10px] bg-brand-base pb-10 text-center shadow-md"> <div className="flex flex-col items-center justify-center gap-7 rounded-[10px] bg-brand-base pb-7 text-center shadow-md">
{step === 4 ? ( {step === 4 ? (
<OnboardingCard data={ONBOARDING_CARDS.welcome} /> <OnboardingCard data={ONBOARDING_CARDS.welcome} />
) : step === 5 ? ( ) : step === 5 ? (
@ -80,7 +83,7 @@ const Onboarding: NextPage = () => {
if (step === 8) { if (step === 8) {
userService userService
.updateUserOnBoard({ userRole }) .updateUserOnBoard({ userRole })
.then(() => { .then(async () => {
mutate<ICurrentUserResponse>( mutate<ICurrentUserResponse>(
CURRENT_USER, CURRENT_USER,
(prevData) => { (prevData) => {
@ -96,7 +99,28 @@ const Onboarding: NextPage = () => {
}, },
false false
); );
router.push("/"); const userWorkspaces = await workspaceService.userWorkspaces();
const lastActiveWorkspace = userWorkspaces.find(
(workspace) => workspace.id === user?.last_workspace_id
);
if (lastActiveWorkspace) {
Router.push(`/${lastActiveWorkspace.slug}`);
return;
} else if (userWorkspaces.length > 0) {
Router.push(`/${userWorkspaces[0].slug}`);
return;
} else {
const invitations = await workspaceService.userWorkspaceInvitations();
if (invitations.length > 0) {
Router.push(`/invitations`);
return;
} else {
Router.push(`/create-workspace`);
return;
}
}
}) })
.catch((err) => { .catch((err) => {
console.log(err); console.log(err);
@ -110,6 +134,10 @@ const Onboarding: NextPage = () => {
</div> </div>
</div> </div>
)} )}
<div className="absolute flex flex-col gap-1 justify-center items-start left-5 top-5">
<span className="text-xs text-brand-secondary">Logged in:</span>
<span className="text-sm text-brand-base">{user?.email}</span>
</div>
</div> </div>
</DefaultLayout> </DefaultLayout>
</UserAuthorizationLayout> </UserAuthorizationLayout>

View File

@ -1,135 +0,0 @@
import React, { useCallback, useState } from "react";
import { useRouter } from "next/router";
import Image from "next/image";
// hooks
import useUser from "hooks/use-user";
import useToast from "hooks/use-toast";
// services
import authenticationService from "services/authentication.service";
// layouts
import DefaultLayout from "layouts/default-layout";
// social button
import {
GoogleLoginButton,
GithubLoginButton,
EmailSignInForm,
EmailPasswordForm,
} from "components/account";
// ui
import { Spinner } from "components/ui";
// icons
import Logo from "public/logo.png";
// types
import type { NextPage } from "next";
const SignInPage: NextPage = () => {
// router
const router = useRouter();
// user hook
const { mutateUser } = useUser();
// states
const [isLoading, setLoading] = useState(false);
const { setToastAlert } = useToast();
const onSignInSuccess = useCallback(async () => {
setLoading(true);
await mutateUser();
const nextLocation = router.asPath.split("?next=")[1];
if (nextLocation) await router.push(nextLocation as string);
else await router.push("/");
}, [mutateUser, router]);
const handleGoogleSignIn = ({ clientId, credential }: any) => {
if (clientId && credential) {
setLoading(true);
authenticationService
.socialAuth({
medium: "google",
credential,
clientId,
})
.then(async () => {
await onSignInSuccess();
})
.catch((err) => {
console.log(err);
setToastAlert({
title: "Error signing in!",
type: "error",
message: "Something went wrong. Please try again later or contact the support team.",
});
setLoading(false);
});
}
};
const handleGithubSignIn = useCallback(
(credential: string) => {
setLoading(true);
authenticationService
.socialAuth({
medium: "github",
credential,
clientId: process.env.NEXT_PUBLIC_GITHUB_ID,
})
.then(async () => {
await onSignInSuccess();
})
.catch((err) => {
console.log(err);
setToastAlert({
title: "Error signing in!",
type: "error",
message: "Something went wrong. Please try again later or contact the support team.",
});
setLoading(false);
});
},
[onSignInSuccess, setToastAlert]
);
return (
<DefaultLayout>
{isLoading ? (
<div className="absolute top-0 left-0 z-50 flex h-full w-full flex-col items-center justify-center gap-y-3">
<h2 className="text-xl text-brand-base">Signing in. Please wait...</h2>
<Spinner />
</div>
) : (
<div className="flex h-screen w-full items-center justify-center overflow-auto">
<div className="flex min-h-full w-full flex-col justify-center py-12 px-6 lg:px-8">
<div className="flex flex-col gap-10 sm:mx-auto sm:w-full sm:max-w-md">
<div className="flex flex-col items-center justify-center gap-10">
<Image src={Logo} height={80} width={80} alt="Plane Web Logo" />
<h2 className="text-center text-xl font-medium text-brand-base">
Sign In to your Plane Account
</h2>
</div>
<div className="flex flex-col rounded-[10px] bg-brand-base shadow-md">
{parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? (
<>
<EmailSignInForm handleSuccess={onSignInSuccess} />
<div className="flex flex-col items-center justify-center gap-3 border-t border-brand-base py-5 px-5 ">
<GoogleLoginButton handleSignIn={handleGoogleSignIn} />
<GithubLoginButton handleSignIn={handleGithubSignIn} />
</div>
</>
) : (
<>
<EmailPasswordForm onSuccess={onSignInSuccess} />
</>
)}
</div>
</div>
</div>
</div>
)}
</DefaultLayout>
);
};
export default SignInPage;

View File

@ -49,7 +49,7 @@ const WorkspaceInvitation: NextPage = () => {
if (email === user?.email) { if (email === user?.email) {
router.push("/invitations"); router.push("/invitations");
} else { } else {
router.push("/signin"); router.push("/");
} }
}) })
.catch((err) => console.error(err)); .catch((err) => console.error(err));
@ -108,7 +108,7 @@ const WorkspaceInvitation: NextPage = () => {
Icon={UserIcon} Icon={UserIcon}
title="Sign in to continue" title="Sign in to continue"
action={() => { action={() => {
router.push("/signin"); router.push("/");
}} }}
/> />
) : ( ) : (

View File

@ -9,7 +9,8 @@ axios.interceptors.response.use(
if (unAuthorizedStatus.includes(status)) { if (unAuthorizedStatus.includes(status)) {
Cookies.remove("refreshToken", { path: "/" }); Cookies.remove("refreshToken", { path: "/" });
Cookies.remove("accessToken", { path: "/" }); Cookies.remove("accessToken", { path: "/" });
if (window.location.pathname != "/signin") window.location.href = "/signin"; if (window.location.pathname != "/")
window.location.href = "/?next_url=window.location.pathname";
} }
return Promise.reject(error); return Promise.reject(error);
} }

View File

@ -17,9 +17,9 @@
} }
:root { :root {
--color-bg-base: 243, 244, 246; --color-bg-base: 255, 255, 255;
--color-bg-surface-1: 249, 250, 251; --color-bg-surface-1: 249, 250, 251;
--color-bg-surface-2: 255, 255, 255; --color-bg-surface-2: 243, 244, 246;
--color-border: 229, 231, 235; --color-border: 229, 231, 235;
--color-bg-sidebar: 255, 255, 255; --color-bg-sidebar: 255, 255, 255;

View File

@ -41,10 +41,11 @@ export interface ICustomTheme {
textSecondary: string; textSecondary: string;
} }
export interface ICurrentUserResponse { export interface ICurrentUserResponse extends IUser {
assigned_issues: number; assigned_issues: number;
user: IUser; // user: IUser;
workspace_invites: number; workspace_invites: number;
is_onboarded: boolean;
} }
export interface IUserLite { export interface IUserLite {

View File

@ -8212,10 +8212,12 @@ svgmoji@^3.2.0:
"@svgmoji/openmoji" "^3.2.0" "@svgmoji/openmoji" "^3.2.0"
"@svgmoji/twemoji" "^3.2.0" "@svgmoji/twemoji" "^3.2.0"
swr@^1.3.0: swr@^2.1.3:
version "1.3.0" version "2.1.3"
resolved "https://registry.yarnpkg.com/swr/-/swr-1.3.0.tgz#c6531866a35b4db37b38b72c45a63171faf9f4e8" resolved "https://registry.yarnpkg.com/swr/-/swr-2.1.3.tgz#ae3608d4dc19f75121e0b51d1982735fbf02f52e"
integrity sha512-dkghQrOl2ORX9HYrMDtPa7LTVHJjCTeZoB1dqTbnnEDlSvN8JEKpYIYurDfvbQFUUS8Cg8PceFVZNkW0KNNYPw== integrity sha512-g3ApxIM4Fjbd6vvEAlW60hJlKcYxHb+wtehogTygrh6Jsw7wNagv9m4Oj5Gq6zvvZw0tcyhVGL9L0oISvl3sUw==
dependencies:
use-sync-external-store "^1.2.0"
table@^6.0.9: table@^6.0.9:
version "6.8.1" version "6.8.1"
@ -8680,7 +8682,7 @@ use-sidecar@^1.1.2:
detect-node-es "^1.1.0" detect-node-es "^1.1.0"
tslib "^2.0.0" tslib "^2.0.0"
use-sync-external-store@1.2.0: use-sync-external-store@1.2.0, use-sync-external-store@^1.2.0:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==