feat(onboarding): frontend for onboarding users

This commit is contained in:
Akhil Mohan
2023-11-10 13:12:58 +05:30
parent df7d8e7be9
commit 29fa85e499
31 changed files with 938 additions and 66 deletions
@@ -336,5 +336,9 @@
"step5-invite-team": "Invite your team", "step5-invite-team": "Invite your team",
"step5-subtitle": "Infisical is meant to be used with your teammates. Invite them to test it out.", "step5-subtitle": "Infisical is meant to be used with your teammates. Invite them to test it out.",
"step5-skip": "Skip" "step5-skip": "Skip"
},
"admin": {
"signup-title": "Admin Sign Up",
"dashboard": "Admin Dashboard"
} }
} }
@@ -1,6 +1,7 @@
import { fetchOrgUsers,fetchUserAction } from "@app/hooks/api/users/queries"; import { fetchOrgUsers, fetchUserAction } from "@app/hooks/api/users/queries";
interface OnboardingCheckProps { interface OnboardingCheckProps {
orgId: string;
setTotalOnboardingActionsDone?: (value: number) => void; setTotalOnboardingActionsDone?: (value: number) => void;
setHasUserClickedSlack?: (value: boolean) => void; setHasUserClickedSlack?: (value: boolean) => void;
setHasUserClickedIntro?: (value: boolean) => void; setHasUserClickedIntro?: (value: boolean) => void;
@@ -12,6 +13,7 @@ interface OnboardingCheckProps {
* This function checks which onboarding steps a user has already finished. * This function checks which onboarding steps a user has already finished.
*/ */
const onboardingCheck = async ({ const onboardingCheck = async ({
orgId,
setTotalOnboardingActionsDone, setTotalOnboardingActionsDone,
setHasUserClickedSlack, setHasUserClickedSlack,
setHasUserClickedIntro, setHasUserClickedIntro,
@@ -19,9 +21,7 @@ const onboardingCheck = async ({
setUsersInOrg setUsersInOrg
}: OnboardingCheckProps) => { }: OnboardingCheckProps) => {
let countActions = 0; let countActions = 0;
const userActionSlack = await fetchUserAction( const userActionSlack = await fetchUserAction("slack_cta_clicked");
"slack_cta_clicked"
);
if (userActionSlack) { if (userActionSlack) {
countActions += 1; countActions += 1;
@@ -41,9 +41,8 @@ const onboardingCheck = async ({
} }
if (setHasUserClickedIntro) setHasUserClickedIntro(!!userActionIntro); if (setHasUserClickedIntro) setHasUserClickedIntro(!!userActionIntro);
const orgId = localStorage.getItem("orgData.id");
const orgUsers = await fetchOrgUsers(orgId || ""); const orgUsers = await fetchOrgUsers(orgId || "");
if (orgUsers.length > 1) { if (orgUsers.length > 1) {
countActions += 1; countActions += 1;
} }
@@ -3,13 +3,15 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
type Props = { type Props = {
text?: string | string[]; text?: string | string[];
frequency?: number; frequency?: number;
className?: string;
}; };
export const ContentLoader = ({ text, frequency = 2000 }: Props) => { export const ContentLoader = ({ text, frequency = 2000, className }: Props) => {
const [pos, setPos] = useState(0); const [pos, setPos] = useState(0);
const isTextArray = Array.isArray(text); const isTextArray = Array.isArray(text);
useEffect(() => { useEffect(() => {
@@ -23,7 +25,12 @@ export const ContentLoader = ({ text, frequency = 2000 }: Props) => {
}, []); }, []);
return ( return (
<div className="container mx-auto flex relative flex-col h-screen w-full items-center justify-center px-8 text-mineshaft-50 dark:[color-scheme:dark] space-y-8"> <div
className={twMerge(
"container mx-auto flex relative flex-col h-screen w-full items-center justify-center px-8 text-mineshaft-50 dark:[color-scheme:dark] space-y-8",
className
)}
>
<img src="/images/loading/loading.gif" height={70} width={120} alt="loading animation" /> <img src="/images/loading/loading.gif" height={70} width={120} alt="loading animation" />
{text && isTextArray && ( {text && isTextArray && (
<AnimatePresence exitBeforeEnter> <AnimatePresence exitBeforeEnter>
+1 -1
View File
@@ -3,7 +3,7 @@ import * as SwitchPrimitive from "@radix-ui/react-switch";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
export type SwitchProps = Omit<SwitchPrimitive.SwitchProps, "checked" | "disabled" | "required"> & { export type SwitchProps = Omit<SwitchPrimitive.SwitchProps, "checked" | "disabled" | "required"> & {
children: ReactNode; children?: ReactNode;
id: string; id: string;
isChecked?: boolean; isChecked?: boolean;
isRequired?: boolean; isRequired?: boolean;
@@ -1,7 +1,5 @@
import { useOrganization, useSubscription } from "@app/context"; import { useOrganization, useSubscription } from "@app/context";
import { import { useGetOrgTrialUrl } from "@app/hooks/api";
useGetOrgTrialUrl
} from "@app/hooks/api";
import { Button } from "../Button"; import { Button } from "../Button";
import { Modal, ModalContent } from "../Modal"; import { Modal, ModalContent } from "../Modal";
@@ -16,38 +14,36 @@ export const UpgradePlanModal = ({ text, isOpen, onOpenChange }: Props): JSX.Ele
const { subscription } = useSubscription(); const { subscription } = useSubscription();
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const { mutateAsync, isLoading } = useGetOrgTrialUrl(); const { mutateAsync, isLoading } = useGetOrgTrialUrl();
const link = (subscription && subscription.slug !== null) const link =
? `/org/${currentOrg?._id}/billing` subscription && subscription.slug !== null
: "https://infisical.com/scheduledemo"; ? `/org/${currentOrg?._id}/billing`
: "https://infisical.com/scheduledemo";
const handleUpgradeBtnClick = async () => { const handleUpgradeBtnClick = async () => {
try { try {
if (!subscription || !currentOrg) return; if (!subscription || !currentOrg) return;
if (!subscription.has_used_trial) { if (!subscription.has_used_trial) {
// direct user to start pro trial // direct user to start pro trial
const url = await mutateAsync({ const url = await mutateAsync({
orgId: currentOrg._id, orgId: currentOrg._id,
success_url: window.location.href success_url: window.location.href
}); });
window.location.href = url; window.location.href = url;
} else { } else {
// direct user to upgrade their plan // direct user to upgrade their plan
window.location.href = link; window.location.href = link;
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} }
} };
return ( return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}> <Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent <ModalContent title="Unleash Infisical's Full Power">
title="Unleash Infisical's Full Power"
>
<p className="mb-2 text-bunker-300">{text}</p> <p className="mb-2 text-bunker-300">{text}</p>
<p className="text-bunker-300"> <p className="text-bunker-300">
Upgrade and get access to this, as well as to other powerful enhancements. Upgrade and get access to this, as well as to other powerful enhancements.
@@ -59,10 +55,10 @@ export const UpgradePlanModal = ({ text, isOpen, onOpenChange }: Props): JSX.Ele
onClick={handleUpgradeBtnClick} onClick={handleUpgradeBtnClick}
className="mr-4" className="mr-4"
> >
{(subscription && !subscription.has_used_trial) ? "Start Pro Free Trial" : "Upgrade Plan"} {subscription && !subscription.has_used_trial ? "Start Pro Free Trial" : "Upgrade Plan"}
</Button> </Button>
<Button <Button
colorSchema="secondary" colorSchema="secondary"
variant="plain" variant="plain"
onClick={() => onOpenChange && onOpenChange(false)} onClick={() => onOpenChange && onOpenChange(false)}
> >
@@ -71,5 +67,5 @@ export const UpgradePlanModal = ({ text, isOpen, onOpenChange }: Props): JSX.Ele
</div> </div>
</ModalContent> </ModalContent>
</Modal> </Modal>
) );
} };
+6 -4
View File
@@ -21,7 +21,8 @@ export const publicPaths = [
"/saml-sso", "/saml-sso",
"/login/provider/success", // TODO: change "/login/provider/success", // TODO: change
"/login/provider/error", // TODO: change "/login/provider/error", // TODO: change
"/login/sso" "/login/sso",
"/admin/signup"
]; ];
export const languageMap = { export const languageMap = {
@@ -50,7 +51,8 @@ const plansProd: Mapping = {
export const plans = plansProd || plansDev; export const plans = plansProd || plansDev;
export const leaveConfirmDefaultMessage = "Your changes will be lost if you leave the page. Are you sure you want to continue?"; export const leaveConfirmDefaultMessage =
"Your changes will be lost if you leave the page. Are you sure you want to continue?";
export const secretTagsColors = [ export const secretTagsColors = [
{ {
@@ -115,5 +117,5 @@ export const secretTagsColors = [
rgba: "rgb(255,0,0, 0.8)", rgba: "rgb(255,0,0, 0.8)",
name: "Red", name: "Red",
selected: false selected: false
}, }
] ];
@@ -18,7 +18,7 @@ type Props = {
// Provide a context for whole app to notify user is authorized or not // Provide a context for whole app to notify user is authorized or not
export const AuthProvider = ({ children }: Props): JSX.Element => { export const AuthProvider = ({ children }: Props): JSX.Element => {
const { isLoading } = useGetAuthToken(); const { isLoading } = useGetAuthToken();
const { pathname, push } = useRouter(); const { pathname, push, asPath } = useRouter();
const [isReady, setIsReady] = useToggle(false); const [isReady, setIsReady] = useToggle(false);
useEffect(() => { useEffect(() => {
@@ -26,7 +26,7 @@ export const AuthProvider = ({ children }: Props): JSX.Element => {
if (!isLoading) { if (!isLoading) {
// not a public path and not authenticated kick to login page // not a public path and not authenticated kick to login page
if (!publicPaths.includes(pathname) && !isLoggedIn()) { if (!publicPaths.includes(pathname) && !isLoggedIn()) {
push("/login").then(() => { push({ pathname: "/login", query: { redirect: asPath } }).then(() => {
setIsReady.on(); setIsReady.on();
}); });
} else { } else {
@@ -40,7 +40,12 @@ export const AuthProvider = ({ children }: Props): JSX.Element => {
if (isLoading || !isReady) { if (isLoading || !isReady) {
return ( return (
<div className="flex items-center justify-center w-screen h-screen bg-bunker-800"> <div className="flex items-center justify-center w-screen h-screen bg-bunker-800">
<img src="/images/loading/loading.gif" height={70} width={120} alt="infisical loading indicator" /> <img
src="/images/loading/loading.gif"
height={70}
width={120}
alt="infisical loading indicator"
/>
</div> </div>
); );
} }
@@ -0,0 +1,53 @@
import { createContext, ReactNode, useContext, useEffect, useMemo } from "react";
import { useRouter } from "next/router";
import { ContentLoader } from "@app/components/v2/ContentLoader";
import { useGetServerConfig } from "@app/hooks/api";
import { TServerConfig } from "@app/hooks/api/admin/types";
type TServerConfigContext = {
config: TServerConfig;
};
const ServerConfigContext = createContext<TServerConfigContext | null>(null);
type Props = {
children: ReactNode;
};
export const ServerConfigProvider = ({ children }: Props): JSX.Element => {
const router = useRouter();
const { data, isLoading } = useGetServerConfig();
// memorize the workspace details for the context
const value = useMemo<TServerConfigContext>(() => {
return {
config: data!
};
}, [data]);
useEffect(() => {
if (!isLoading && data && !data.initialized) {
router.push("/admin/signup");
}
}, [isLoading, data]);
if (isLoading || (!data?.initialized && router.pathname !== "/admin/signup")) {
return (
<div className="bg-bunker-800">
<ContentLoader text="Loading configurations" />
</div>
);
}
return <ServerConfigContext.Provider value={value}>{children}</ServerConfigContext.Provider>;
};
export const useServerConfig = () => {
const ctx = useContext(ServerConfigContext);
if (!ctx) {
throw new Error("useServerConfig has to be used within <UserContext.Provider>");
}
return ctx;
};
@@ -0,0 +1 @@
export { ServerConfigProvider,useServerConfig } from "./ServerConfigContext";
+1
View File
@@ -14,6 +14,7 @@ export {
ProjectPermissionSub, ProjectPermissionSub,
useProjectPermission useProjectPermission
} from "./ProjectPermissionContext"; } from "./ProjectPermissionContext";
export { ServerConfigProvider,useServerConfig } from "./ServerConfigContext";
export { SubscriptionProvider, useSubscription } from "./SubscriptionContext"; export { SubscriptionProvider, useSubscription } from "./SubscriptionContext";
export { UserProvider, useUser } from "./UserContext"; export { UserProvider, useUser } from "./UserContext";
export { useWorkspace, WorkspaceProvider } from "./WorkspaceContext"; export { useWorkspace, WorkspaceProvider } from "./WorkspaceContext";
+2
View File
@@ -0,0 +1,2 @@
export { useCreateAdminUser, useUpdateServerConfig } from "./mutation";
export { useGetServerConfig } from "./queries";
+39
View File
@@ -0,0 +1,39 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { User } from "../users/types";
import { adminQueryKeys } from "./queries";
import { TCreateAdminUserDTO, TServerConfig } from "./types";
export const useCreateAdminUser = () => {
const queryClient = useQueryClient();
return useMutation<{ user: User; token: string }, {}, TCreateAdminUserDTO>({
mutationFn: async (opt) => {
const { data } = await apiRequest.post("/api/v1/admin/signup", opt);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries(adminQueryKeys.serverConfig());
}
});
};
export const useUpdateServerConfig = () => {
const queryClient = useQueryClient();
return useMutation<TServerConfig, {}, Partial<TServerConfig>>({
mutationFn: async (opt) => {
const { data } = await apiRequest.patch<{ config: TServerConfig }>(
"/api/v1/admin/config",
opt
);
return data.config;
},
onSuccess: (data) => {
queryClient.setQueryData(adminQueryKeys.serverConfig(), data);
queryClient.invalidateQueries(adminQueryKeys.serverConfig());
}
});
};
+34
View File
@@ -0,0 +1,34 @@
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { TServerConfig } from "./types";
export const adminQueryKeys = {
serverConfig: () => ["server-config"] as const
};
const fetchServerConfig = async () => {
const { data } = await apiRequest.get<{ config: TServerConfig }>("/api/v1/admin/config");
return data.config;
};
export const useGetServerConfig = ({
options = {}
}: {
options?: Omit<
UseQueryOptions<
TServerConfig,
unknown,
TServerConfig,
ReturnType<typeof adminQueryKeys.serverConfig>
>,
"queryKey" | "queryFn"
>;
} = {}) =>
useQuery({
queryKey: adminQueryKeys.serverConfig(),
queryFn: fetchServerConfig,
...options,
enabled: options?.enabled ?? true
});
+19
View File
@@ -0,0 +1,19 @@
export type TServerConfig = {
initialized: boolean;
allowSignUp: boolean;
};
export type TCreateAdminUserDTO = {
email: string;
firstName: string;
lastName?: string;
protectedKey: string;
protectedKeyTag: string;
protectedKeyIV: string;
encryptedPrivateKey: string;
encryptedPrivateKeyIV: string;
encryptedPrivateKeyTag: string;
publicKey: string;
verifier: string;
salt: string;
};
+1
View File
@@ -1,4 +1,5 @@
export * from "./apiKeys"; export * from "./apiKeys";
export * from "./admin"
export * from "./auditLogs"; export * from "./auditLogs";
export * from "./auth"; export * from "./auth";
export * from "./bots"; export * from "./bots";
+1
View File
@@ -14,6 +14,7 @@ export type User = {
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
email: string; email: string;
superAdmin: boolean;
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
authProvider?: AuthMethod; authProvider?: AuthMethod;
@@ -0,0 +1,306 @@
/* eslint-disable no-nested-ternary */
/* eslint-disable no-unexpected-multiline */
/* eslint-disable react-hooks/exhaustive-deps */
/* eslint-disable vars-on-top */
/* eslint-disable no-var */
/* eslint-disable func-names */
// @ts-nocheck
import { useTranslation } from "react-i18next";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/router";
import { faGithub, faSlack } from "@fortawesome/free-brands-svg-icons";
import {
faArrowLeft,
faArrowUpRightFromSquare,
faBook,
faEnvelope,
faInfinity,
faInfo,
faMobile,
faPlus,
faQuestion
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@app/components/v2";
import { useOrganization, useSubscription, useUser } from "@app/context";
import {
useGetOrgTrialUrl,
useGetUserAction,
useLogoutUser,
useRegisterUserAction
} from "@app/hooks/api";
interface LayoutProps {
children: React.ReactNode;
}
const supportOptions = [
[
<FontAwesomeIcon key={1} className="pr-4 text-sm" icon={faSlack} />,
"Support Forum",
"https://infisical.com/slack"
],
[
<FontAwesomeIcon key={2} className="pr-4 text-sm" icon={faBook} />,
"Read Docs",
"https://infisical.com/docs/documentation/getting-started/introduction"
],
[
<FontAwesomeIcon key={3} className="pr-4 text-sm" icon={faGithub} />,
"GitHub Issues",
"https://github.com/Infisical/infisical/issues"
],
[
<FontAwesomeIcon key={4} className="pr-4 text-sm" icon={faEnvelope} />,
"Email Support",
"mailto:[email protected]"
]
];
export const AdminLayout = ({ children }: LayoutProps) => {
const router = useRouter();
const { mutateAsync } = useGetOrgTrialUrl();
// eslint-disable-next-line prefer-const
const { currentOrg } = useOrganization();
const { user } = useUser();
const { subscription } = useSubscription();
const { data: updateClosed } = useGetUserAction("september_update_closed");
const infisicalPlatformVersion = process.env.NEXT_PUBLIC_INFISICAL_PLATFORM_VERSION;
const { t } = useTranslation();
const registerUserAction = useRegisterUserAction();
const closeUpdate = async () => {
await registerUserAction.mutateAsync("september_update_closed");
};
const logout = useLogoutUser();
const logOutUser = async () => {
try {
console.log("Logging out...");
await logout.mutateAsync();
router.push("/login");
} catch (error) {
console.error(error);
}
};
return (
<>
<div className="dark hidden h-screen w-full flex-col overflow-x-hidden md:flex">
<div className="flex flex-grow flex-col overflow-y-hidden md:flex-row">
<aside className="dark w-full border-r border-mineshaft-600 bg-gradient-to-tr from-mineshaft-700 via-mineshaft-800 to-mineshaft-900 md:w-60">
<nav className="items-between flex h-full flex-col justify-between overflow-y-auto dark:[color-scheme:dark]">
<div>
{!router.asPath.includes("personal") && (
<div className="flex h-12 cursor-default justify-between items-center px-3 pt-6">
<Link href={`/org/${currentOrg?._id}/overview`}>
<div className="my-6 flex cursor-default items-center justify-center pr-2 text-sm text-mineshaft-300 hover:text-mineshaft-100">
<FontAwesomeIcon icon={faArrowLeft} className="pr-3" />
Back to organization
</div>
</Link>
<DropdownMenu>
<DropdownMenuTrigger
asChild
className="p-1 hover:bg-primary-400 hover:text-black data-[state=open]:bg-primary-400 data-[state=open]:text-black"
>
<div
className="child flex items-center justify-center rounded-full bg-mineshaft pr-1 text-mineshaft-300 hover:bg-mineshaft-500"
style={{ fontSize: "11px", width: "26px", height: "26px" }}
>
{user?.firstName?.charAt(0)}
{user?.lastName && user?.lastName?.charAt(0)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<div className="px-2 py-1 text-xs text-mineshaft-400">{user?.email}</div>
<Link href="/personal-settings">
<DropdownMenuItem>Personal Settings</DropdownMenuItem>
</Link>
<a
href="https://infisical.com/docs/documentation/getting-started/introduction"
target="_blank"
rel="noopener noreferrer"
className="mt-3 w-full text-sm font-normal leading-[1.2rem] text-mineshaft-300 hover:text-mineshaft-100"
>
<DropdownMenuItem>
Documentation
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.06rem] pl-1.5 text-xxs"
/>
</DropdownMenuItem>
</a>
<a
href="https://infisical.com/slack"
target="_blank"
rel="noopener noreferrer"
className="mt-3 w-full text-sm font-normal leading-[1.2rem] text-mineshaft-300 hover:text-mineshaft-100"
>
<DropdownMenuItem>
Join Slack Community
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.06rem] pl-1.5 text-xxs"
/>
</DropdownMenuItem>
</a>
{user?.superAdmin && (
<Link href="/admin" legacyBehavior>
<DropdownMenuItem className="mt-1 border-t border-mineshaft-600">
Admin Panel
</DropdownMenuItem>
</Link>
)}
<div className="mt-1 h-1 border-t border-mineshaft-600" />
<button type="button" onClick={logOutUser} className="w-full">
<DropdownMenuItem>Log Out</DropdownMenuItem>
</button>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
<div
className={`relative mt-10 ${
subscription && subscription.slug === "starter" && !subscription.has_used_trial
? "mb-2"
: "mb-4"
} flex w-full cursor-default flex-col items-center px-3 text-sm text-mineshaft-400`}
>
<div
className={`${
!updateClosed ? "block" : "hidden"
} relative z-10 mb-6 flex pb-2 w-52 flex-col items-center justify-start rounded-md border border-mineshaft-600 bg-mineshaft-900 px-3`}
>
<div className="text-md mt-2 w-full font-semibold text-mineshaft-100">
Infisical September update
</div>
<div className="mt-1 mb-1 w-full text-sm font-normal leading-[1.2rem] text-mineshaft-300">
Improved RBAC, new integrations, dashboard remake, and more!
</div>
<div className="mt-2 h-[6.77rem] w-full rounded-md border border-mineshaft-700">
<Image
src="/images/infisical-update-september-2023.png"
height={319}
width={539}
alt="kubernetes image"
className="rounded-sm"
/>
</div>
<div className="mt-3 flex w-full items-center justify-between px-0.5">
<button
type="button"
onClick={() => closeUpdate()}
className="text-mineshaft-400 duration-200 hover:text-mineshaft-100"
>
Close
</button>
<a
href="https://infisical.com/blog/infisical-update-september-2023"
target="_blank"
rel="noopener noreferrer"
className="text-sm font-normal leading-[1.2rem] text-mineshaft-400 duration-200 hover:text-mineshaft-100"
>
Learn More{" "}
<FontAwesomeIcon icon={faArrowUpRightFromSquare} className="pl-0.5 text-xs" />
</a>
</div>
</div>
{router.asPath.includes("org") && (
<div
onKeyDown={() => null}
role="button"
tabIndex={0}
onClick={() => router.push(`/org/${router.query.id}/members?action=invite`)}
className="w-full"
>
<div className="mb-3 w-full pl-5 duration-200 hover:text-mineshaft-200">
<FontAwesomeIcon icon={faPlus} className="mr-3" />
Invite people
</div>
</div>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="mb-2 w-full pl-5 duration-200 hover:text-mineshaft-200">
<FontAwesomeIcon icon={faQuestion} className="mr-3 px-[0.1rem]" />
Help & Support
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
{supportOptions.map(([icon, text, url]) => (
<DropdownMenuItem key={url}>
<a
target="_blank"
rel="noopener noreferrer"
href={String(url)}
className="flex w-full items-center rounded-md font-normal text-mineshaft-300 duration-200"
>
<div className="relative flex w-full cursor-pointer select-none items-center justify-start rounded-md">
{icon}
<div className="text-sm">{text}</div>
</div>
</a>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{subscription &&
subscription.slug === "starter" &&
!subscription.has_used_trial && (
<button
type="button"
onClick={async () => {
if (!subscription || !currentOrg) return;
// direct user to start pro trial
const url = await mutateAsync({
orgId: currentOrg._id,
success_url: window.location.href
});
window.location.href = url;
}}
className="mt-1.5 w-full"
>
<div className="justify-left mb-1.5 mt-1.5 flex w-full items-center rounded-md bg-mineshaft-600 py-1 pl-4 text-mineshaft-300 duration-200 hover:bg-mineshaft-500 hover:text-primary-400">
<FontAwesomeIcon
icon={faInfinity}
className="mr-3 ml-0.5 py-2 text-primary"
/>
Start Free Pro Trial
</div>
</button>
)}
{infisicalPlatformVersion && (
<div className="mb-2 w-full pl-5 duration-200 hover:text-mineshaft-200">
<FontAwesomeIcon icon={faInfo} className="mr-4 px-[0.1rem]" />
Version: {infisicalPlatformVersion}
</div>
)}
</div>
</nav>
</aside>
<main className="flex-1 overflow-y-auto overflow-x-hidden bg-bunker-800 dark:[color-scheme:dark]">
{children}
</main>
</div>
</div>
<div className="z-[200] flex h-screen w-screen flex-col items-center justify-center bg-bunker-800 md:hidden">
<FontAwesomeIcon icon={faMobile} className="mb-8 text-7xl text-gray-300" />
<p className="max-w-sm px-6 text-center text-lg text-gray-200">
{` ${t("common.no-mobile")} `}
</p>
</div>
</>
);
};
@@ -0,0 +1 @@
export { AdminLayout } from "./AdminLayout";
+8 -1
View File
@@ -399,6 +399,13 @@ export const AppLayout = ({ children }: LayoutProps) => {
/> />
</DropdownMenuItem> </DropdownMenuItem>
</a> </a>
{user?.superAdmin && (
<Link href="/admin" legacyBehavior>
<DropdownMenuItem className="mt-1 border-t border-mineshaft-600">
Admin Panel
</DropdownMenuItem>
</Link>
)}
<div className="mt-1 h-1 border-t border-mineshaft-600" /> <div className="mt-1 h-1 border-t border-mineshaft-600" />
<button type="button" onClick={logOutUser} className="w-full"> <button type="button" onClick={logOutUser} className="w-full">
<DropdownMenuItem>Log Out</DropdownMenuItem> <DropdownMenuItem>Log Out</DropdownMenuItem>
@@ -541,7 +548,7 @@ export const AppLayout = ({ children }: LayoutProps) => {
</MenuItem> </MenuItem>
</a> </a>
</Link> </Link>
{/* <Link href={`/project/${currentWorkspace?._id}/allowlist`} passHref> {/* <Link href={`/project/${currentWorkspace?._id}/allowlist`} passHref>
<a> <a>
<MenuItem <MenuItem
+1
View File
@@ -1 +1,2 @@
export { AdminLayout } from "./AdminLayout";
export { AppLayout } from "./AppLayout"; export { AppLayout } from "./AppLayout";
+96
View File
@@ -0,0 +1,96 @@
import crypto from "crypto";
import jsrp from "jsrp";
import nacl from "tweetnacl";
import { encodeBase64 } from "tweetnacl-util";
import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm";
import { deriveArgonKey } from "@app/components/utilities/cryptography/crypto";
// eslint-disable-next-line new-cap
const client = new jsrp.client();
type TUserPassKey = {
protectedKey: string;
protectedKeyTag: string;
protectedKeyIV: string;
encryptedPrivateKey: string;
encryptedPrivateKeyIV: string;
encryptedPrivateKeyTag: string;
publicKey: string;
verifier: string;
salt: string;
privateKey: string;
};
export const generateUserPassKey = async (email: string, password: string) => {
const pair = nacl.box.keyPair();
const secretKeyUint8Array = pair.secretKey;
const publicKeyUint8Array = pair.publicKey;
const privateKey = encodeBase64(secretKeyUint8Array);
const publicKey = encodeBase64(publicKeyUint8Array);
return new Promise<TUserPassKey>((resolve, reject) => {
client.init({ username: email, password }, () => {
client.createVerifier(
async (err: any, { salt, verifier }: { salt: string; verifier: string }) => {
if (err) {
return reject(err);
}
try {
// TODO: moduralize into KeyService
const derivedKey = await deriveArgonKey({
password,
salt,
mem: 65536,
time: 3,
parallelism: 1,
hashLen: 32
});
if (!derivedKey) throw new Error("Failed to derive key from password");
const key = crypto.randomBytes(32);
// create encrypted private key by encrypting the private
// key with the symmetric key [key]
const {
ciphertext: encryptedPrivateKey,
iv: encryptedPrivateKeyIV,
tag: encryptedPrivateKeyTag
} = Aes256Gcm.encrypt({
text: privateKey,
secret: key
});
// create the protected key by encrypting the symmetric key
// [key] with the derived key
const {
ciphertext: protectedKey,
iv: protectedKeyIV,
tag: protectedKeyTag
} = Aes256Gcm.encrypt({
text: key.toString("hex"),
secret: Buffer.from(derivedKey.hash)
});
return resolve({
protectedKey,
protectedKeyTag,
protectedKeyIV,
encryptedPrivateKey,
encryptedPrivateKeyIV,
encryptedPrivateKeyTag,
publicKey,
verifier,
salt,
privateKey
});
} catch (error) {
return reject(error);
}
}
);
});
});
};
+29 -22
View File
@@ -20,6 +20,7 @@ import {
OrgPermissionProvider, OrgPermissionProvider,
OrgProvider, OrgProvider,
ProjectPermissionProvider, ProjectPermissionProvider,
ServerConfigProvider,
SubscriptionProvider, SubscriptionProvider,
UserProvider, UserProvider,
WorkspaceProvider WorkspaceProvider
@@ -84,36 +85,42 @@ const App = ({ Component, pageProps, ...appProps }: NextAppProp): JSX.Element =>
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<NotificationProvider> <NotificationProvider>
<AuthProvider> <ServerConfigProvider>
<Component {...pageProps} /> <AuthProvider>
</AuthProvider> <Component {...pageProps} />
</AuthProvider>
</ServerConfigProvider>
</NotificationProvider> </NotificationProvider>
</QueryClientProvider> </QueryClientProvider>
); );
} }
const Layout = Component?.layout || AppLayout;
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<TooltipProvider> <TooltipProvider>
<AuthProvider> <NotificationProvider>
<OrgProvider> <ServerConfigProvider>
<OrgPermissionProvider> <AuthProvider>
<WorkspaceProvider> <OrgProvider>
<ProjectPermissionProvider> <OrgPermissionProvider>
<SubscriptionProvider> <WorkspaceProvider>
<UserProvider> <ProjectPermissionProvider>
<NotificationProvider> <SubscriptionProvider>
<AppLayout> <UserProvider>
<Component {...pageProps} /> <Layout>
</AppLayout> <Component {...pageProps} />
</NotificationProvider> </Layout>
</UserProvider> </UserProvider>
</SubscriptionProvider> </SubscriptionProvider>
</ProjectPermissionProvider> </ProjectPermissionProvider>
</WorkspaceProvider> </WorkspaceProvider>
</OrgPermissionProvider> </OrgPermissionProvider>
</OrgProvider> </OrgProvider>
</AuthProvider> </AuthProvider>
</ServerConfigProvider>
</NotificationProvider>
</TooltipProvider> </TooltipProvider>
</QueryClientProvider> </QueryClientProvider>
); );
+30
View File
@@ -0,0 +1,30 @@
import { useTranslation } from "react-i18next";
import Head from "next/head";
import { AdminLayout } from "@app/layouts";
import { AdminDashboardPage } from "@app/views/admin/DashboardPage";
const AdminDashboard = () => {
const { t } = useTranslation();
return (
<>
<Head>
<title>{t("common.head-title", { title: t("admin.dashboard") })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content={t("admin.dashboard.og-title") ?? ""} />
<meta name="og:description" content={t("admin.dashboard.og-description") ?? ""} />
</Head>
<div className="h-full">
<AdminDashboardPage />
</div>
</>
);
};
export default AdminDashboard;
AdminDashboard.requireAuth = true;
AdminDashboard.layout = AdminLayout;
+21
View File
@@ -0,0 +1,21 @@
import { useTranslation } from "react-i18next";
import Head from "next/head";
import { SignUpPage } from "@app/views/admin/SignUpPage";
export default function LoginPage() {
const { t } = useTranslation();
return (
<div className="flex min-h-screen max-h-screen overflow-y-auto flex-col justify-center bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700 px-6">
<Head>
<title>{t("common.head-title", { title: t("signup.title") })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content={t("signup.og-title") ?? ""} />
<meta name="og:description" content={t("signup.og-description") ?? ""} />
</Head>
<SignUpPage />
</div>
);
}
@@ -559,6 +559,7 @@ const OrganizationPage = withPermission(
useEffect(() => { useEffect(() => {
onboardingCheck({ onboardingCheck({
orgId: currentOrg,
setHasUserClickedIntro, setHasUserClickedIntro,
setHasUserClickedSlack, setHasUserClickedSlack,
setHasUserPushedSecrets, setHasUserPushedSecrets,
+9 -1
View File
@@ -12,6 +12,7 @@ import InitialSignupStep from "@app/components/signup/InitialSignupStep";
import TeamInviteStep from "@app/components/signup/TeamInviteStep"; import TeamInviteStep from "@app/components/signup/TeamInviteStep";
import UserInfoStep from "@app/components/signup/UserInfoStep"; import UserInfoStep from "@app/components/signup/UserInfoStep";
import SecurityClient from "@app/components/utilities/SecurityClient"; import SecurityClient from "@app/components/utilities/SecurityClient";
import { useServerConfig } from "@app/context";
import { useVerifyEmailVerificationCode } from "@app/hooks/api"; import { useVerifyEmailVerificationCode } from "@app/hooks/api";
import { fetchOrganizations } from "@app/hooks/api/organization/queries"; import { fetchOrganizations } from "@app/hooks/api/organization/queries";
import { useFetchServerStatus } from "@app/hooks/api/serverDetails"; import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
@@ -34,6 +35,13 @@ export default function SignUp() {
const [isCodeInputCheckLoading, setIsCodeInputCheckLoading] = useState(false); const [isCodeInputCheckLoading, setIsCodeInputCheckLoading] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const { mutateAsync } = useVerifyEmailVerificationCode(); const { mutateAsync } = useVerifyEmailVerificationCode();
const { config } = useServerConfig();
useEffect(() => {
if (!config.allowSignUp) {
router.push("/login");
}
}, [config.allowSignUp]);
useEffect(() => { useEffect(() => {
const tryAuth = async () => { const tryAuth = async () => {
@@ -65,7 +73,7 @@ export default function SignUp() {
const { token } = await mutateAsync({ email, code }); const { token } = await mutateAsync({ email, code });
SecurityClient.setSignupToken(token); SecurityClient.setSignupToken(token);
setStep(3); setStep(3);
} catch(err) { } catch (err) {
console.error(err); console.error(err);
setCodeError(true); setCodeError(true);
} }
@@ -12,7 +12,7 @@ import { useNotificationContext } from "@app/components/context/Notifications/No
import attemptCliLogin from "@app/components/utilities/attemptCliLogin"; import attemptCliLogin from "@app/components/utilities/attemptCliLogin";
import attemptLogin from "@app/components/utilities/attemptLogin"; import attemptLogin from "@app/components/utilities/attemptLogin";
import { Button, Input } from "@app/components/v2"; import { Button, Input } from "@app/components/v2";
import { useFetchServerStatus } from "@app/hooks/api/serverDetails"; import { useServerConfig } from "@app/context";
import { navigateUserToOrg } from "../../Login.utils"; import { navigateUserToOrg } from "../../Login.utils";
@@ -30,7 +30,7 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }:
const { t } = useTranslation(); const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [loginError, setLoginError] = useState(false); const [loginError, setLoginError] = useState(false);
const { data: serverDetails } = useFetchServerStatus(); const { config } = useServerConfig();
const queryParams = new URLSearchParams(window.location.search); const queryParams = new URLSearchParams(window.location.search);
const handleLogin = async (e: FormEvent<HTMLFormElement>) => { const handleLogin = async (e: FormEvent<HTMLFormElement>) => {
@@ -84,7 +84,7 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }:
setIsLoading(false); setIsLoading(false);
return; return;
} }
await navigateUserToOrg(router); await navigateUserToOrg(router);
// case: login does not require MFA step // case: login does not require MFA step
@@ -225,7 +225,7 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }:
</Button> </Button>
</div> </div>
{!isLoading && loginError && <Error text={t("login.error-login") ?? ""} />} {!isLoading && loginError && <Error text={t("login.error-login") ?? ""} />}
{!serverDetails?.inviteOnlySignup ? ( {config.allowSignUp ? (
<div className="mt-6 flex flex-row text-sm text-bunker-400"> <div className="mt-6 flex flex-row text-sm text-bunker-400">
<Link href="/signup"> <Link href="/signup">
<span className="cursor-pointer duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4"> <span className="cursor-pointer duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4">
@@ -236,7 +236,7 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }:
) : ( ) : (
<div /> <div />
)} )}
<div className="flex flex-row text-sm text-bunker-400"> <div className="flex flex-row text-sm text-bunker-400 mt-2">
<Link href="/verify-email"> <Link href="/verify-email">
<span className="cursor-pointer duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4"> <span className="cursor-pointer duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4">
Forgot password? Recover your account Forgot password? Recover your account
@@ -0,0 +1,64 @@
import { useEffect } from "react";
import { useRouter } from "next/router";
import { ContentLoader, Switch, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { useOrganization, useServerConfig, useUser } from "@app/context";
import { useUpdateServerConfig } from "@app/hooks/api";
enum TabSections {
Settings = "settings"
}
export const AdminDashboardPage = () => {
const router = useRouter();
const data = useServerConfig();
const { config } = data;
const { user, isLoading: isUserLoading } = useUser();
const { orgs } = useOrganization();
const { mutate: updateServerConfig } = useUpdateServerConfig();
const isNotAllowed = !user?.superAdmin;
useEffect(() => {
if (isNotAllowed && !isUserLoading) {
if (orgs?.length) {
localStorage.setItem("orgData.id", orgs?.[0]?._id);
router.push(`/org/${orgs?.[0]?._id}/overview`);
}
}
}, [isNotAllowed, isUserLoading]);
return (
<div className="container mx-auto max-w-7xl pb-12 text-white dark:[color-scheme:dark]">
<div className="mb-8">
<div className="mx-4 mb-4 mt-6 flex flex-col items-start justify-between px-2 text-xl">
<h1 className="text-3xl font-semibold">Admin Dashboard</h1>
<p className="text-base text-bunker-300">Manage your Infisical.</p>
</div>
</div>
{isUserLoading || isNotAllowed ? (
<ContentLoader text={isNotAllowed ? "Redirecting to org page..." : undefined} />
) : (
<div>
<Tabs defaultValue={TabSections.Settings}>
<TabList>
<div className="flex flex-row border-b border-mineshaft-600 w-full">
<Tab value={TabSections.Settings}>General</Tab>
</div>
</TabList>
<TabPanel value={TabSections.Settings}>
<div className="flex items-center space-x-4">
<Switch
id="disable-invite"
isChecked={Boolean(config?.allowSignUp)}
onCheckedChange={(isChecked) => updateServerConfig({ allowSignUp: isChecked })}
/>
<div className="flex-grow">Enable signup or invite</div>
</div>
</TabPanel>
</Tabs>
</div>
)}
</div>
);
};
@@ -0,0 +1 @@
export { AdminDashboardPage } from "./DashboardPage";
@@ -0,0 +1,164 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { useRouter } from "next/router";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
// TODO(akhilmhdh): rewrite this into module functions in lib
import { saveTokenToLocalStorage } from "@app/components/utilities/saveTokenToLocalStorage";
import SecurityClient from "@app/components/utilities/SecurityClient";
import { Button, ContentLoader, FormControl, Input } from "@app/components/v2";
import { useServerConfig } from "@app/context";
import { useCreateAdminUser } from "@app/hooks/api";
import { generateUserPassKey } from "@app/lib/crypto";
import { isLoggedIn } from "@app/reactQuery";
const formSchema = z
.object({
email: z.string().email().trim(),
firstName: z.string().trim(),
lastName: z.string().trim().optional(),
password: z.string().trim().min(14).max(100),
confirmPassword: z.string().trim()
})
.refine((data) => data.password === data.confirmPassword, {
message: "Password don't match",
path: ["confirmPassword"]
});
type TFormSchema = z.infer<typeof formSchema>;
export const SignUpPage = () => {
const router = useRouter();
const {
control,
handleSubmit,
formState: { isSubmitting }
} = useForm<TFormSchema>({
resolver: zodResolver(formSchema)
});
const { createNotification } = useNotificationContext();
const { config } = useServerConfig();
useEffect(() => {
if (config?.initialized) {
if (isLoggedIn()) {
router.push("/admin");
} else {
router.push("/login");
}
}
}, [config.initialized]);
const { mutateAsync: createAdminUser } = useCreateAdminUser();
const handleFormSubmit = async ({ email, password, firstName, lastName }: TFormSchema) => {
// avoid multi submission
if (isSubmitting) return;
try {
const { privateKey, ...userPass } = await generateUserPassKey(email, password);
const res = await createAdminUser({
email,
firstName,
lastName,
...userPass
});
SecurityClient.setToken(res.token);
saveTokenToLocalStorage({
publicKey: userPass.publicKey,
encryptedPrivateKey: userPass.encryptedPrivateKey,
iv: userPass.encryptedPrivateKeyIV,
tag: userPass.encryptedPrivateKeyTag,
privateKey
});
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "Faield to create admin"
});
}
};
if (config?.initialized) return <ContentLoader text="Redirecting to admin page..." />;
return (
<div className="flex justify-center items-center">
<div className="text-mineshaft-200">
<div className="text-center flex flex-col items-center space-y-4">
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical logo" />
<div className="text-4xl">Welcome to Infisical</div>
<div>Create your first Admin Account</div>
</div>
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="mt-8">
<div className="flex items-center space-x-4">
<Controller
control={control}
name="firstName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="First name"
errorText={error?.message}
isError={Boolean(error)}
>
<Input isFullWidth size="lg" {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="lastName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Last name"
errorText={error?.message}
isError={Boolean(error)}
>
<Input isFullWidth size="lg" {...field} />
</FormControl>
)}
/>
</div>
<Controller
control={control}
name="email"
render={({ field, fieldState: { error } }) => (
<FormControl label="Email" errorText={error?.message} isError={Boolean(error)}>
<Input isFullWidth size="lg" {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="password"
render={({ field, fieldState: { error } }) => (
<FormControl label="Password" errorText={error?.message} isError={Boolean(error)}>
<Input isFullWidth size="lg" type="password" {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="confirmPassword"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Confirm password"
errorText={error?.message}
isError={Boolean(error)}
>
<Input isFullWidth size="lg" type="password" {...field} />
</FormControl>
)}
/>
</div>
<Button type="submit" isFullWidth className="mt-4" isLoading={isSubmitting}>
Let&apos;s Go
</Button>
</form>
</div>
</div>
);
};
@@ -0,0 +1 @@
export { SignUpPage } from "./SignUpPage";