feat: cloud and ee (#805)

* stripe init
git submodules for enterprise modules

* * Cloud billing UI - WIP
* Proxy websockets in dev mode
* Separate workspace login and creation for cloud
* Other fixes

* feat: billing (cloud)

* * add domain service
* prepare links from workspace hostname

* WIP

* Add exchange token generation
* Validate JWT token type during verification

* domain service

* add SkipTransform decorator

* * updates (server)
* add new packages
* new sso migration file

* WIP

* Fix hostname generation

* WIP

* WIP

* Reduce input error font-size
* set max password length

* jwt package

* license page - WIP

* * License management UI
* Move license key store to db

* add reflector

* SSO enforcement

* * Add default plan
* Add usePlan hook

* * Fix auth container margin in mobile
* Redirect login and home to select page in cloud

* update .gitignore

* Default to yearly

* * Trial messaging
* Handle ended trials

* Don't set to readonly on collab disconnect (Cloud)

* Refine trial (UI)
* Fix bug caused by using jotai optics atom in AppHeader component

* configurable database maximum pool

* Close SSO form on save

* wip

* sync

* Only show sign-in in cloud

* exclude base api part from workspaceId check

* close db connection beforeApplicationShutdown

* Add health/live endpoint

* clear cookie on hostname change

* reset currentUser atom

* Change text

* return 401 if workspace does not match

* feat: show user workspace list in cloud login page

* sync

* Add home path

* Prefetch to speed up queries

* * Add robots.txt
* Disallow login and forgot password routes

* wildcard user-agent

* Fix space query cache

* fix

* fix

* use space uuid for recent pages

* prefetch billing plans

* enhance license page

* sync
This commit is contained in:
Philip Okugbe
2025-03-06 13:38:37 +00:00
committed by GitHub
parent 91596be70e
commit b81c9ee10c
148 changed files with 8947 additions and 3458 deletions

View File

@ -2,4 +2,17 @@
box-shadow: rgba(0, 0, 0, 0.07) 0px 2px 45px 4px;
border-radius: 4px;
background: light-dark(var(--mantine-color-body), rgba(0, 0, 0, 0.1));
margin-top: 150px;
margin-bottom: 20px;
@media (max-width: $mantine-breakpoint-sm) {
margin-top: 50px;
margin-bottom: 20px;
}
}
.containerBox {
margin-top: 40px;
}

View File

@ -35,8 +35,8 @@ export function ForgotPasswordForm() {
}
return (
<Container size={420} my={40} className={classes.container}>
<Box p="xl" mt={200}>
<Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md">
{t("Forgot password")}
</Title>

View File

@ -65,8 +65,8 @@ export function InviteSignUpForm() {
}
return (
<Container size={420} my={40} className={classes.container}>
<Box p="xl" mt={200}>
<Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md">
{t("Join the workspace")}
</Title>

View File

@ -10,12 +10,17 @@ import {
PasswordInput,
Box,
Anchor,
Group,
} from "@mantine/core";
import classes from "./auth.module.css";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
import { Link, useNavigate } from "react-router-dom";
import { Link } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route.ts";
import { useTranslation } from "react-i18next";
import SsoLogin from "@/ee/components/sso-login.tsx";
import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts";
import { Error404 } from "@/components/ui/error-404.tsx";
import React from "react";
const formSchema = z.object({
email: z
@ -29,6 +34,12 @@ export function LoginForm() {
const { t } = useTranslation();
const { signIn, isLoading } = useAuth();
useRedirectIfAuthenticated();
const {
data,
isLoading: isDataLoading,
isError,
error,
} = useWorkspacePublicDataQuery();
const form = useForm<ILogin>({
validate: zodResolver(formSchema),
@ -42,44 +53,60 @@ export function LoginForm() {
await signIn(data);
}
if (isDataLoading) {
return null;
}
if (isError && error?.["response"]?.status === 404) {
return <Error404 />;
}
return (
<Container size={420} my={40} className={classes.container}>
<Box p="xl" mt={200}>
<Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md">
{t("Login")}
</Title>
<form onSubmit={form.onSubmit(onSubmit)}>
<TextInput
id="email"
type="email"
label={t("Email")}
placeholder="email@example.com"
variant="filled"
{...form.getInputProps("email")}
/>
<SsoLogin />
<PasswordInput
label={t("Password")}
placeholder={t("Your password")}
variant="filled"
mt="md"
{...form.getInputProps("password")}
/>
{!data?.enforceSso && (
<>
<form onSubmit={form.onSubmit(onSubmit)}>
<TextInput
id="email"
type="email"
label={t("Email")}
placeholder="email@example.com"
variant="filled"
{...form.getInputProps("email")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
{t("Sign In")}
</Button>
</form>
<PasswordInput
label={t("Password")}
placeholder={t("Your password")}
variant="filled"
mt="md"
{...form.getInputProps("password")}
/>
<Anchor
to={APP_ROUTE.AUTH.FORGOT_PASSWORD}
component={Link}
underline="never"
size="sm"
>
{t("Forgot your password?")}
</Anchor>
<Group justify="flex-end" mt="sm">
<Anchor
to={APP_ROUTE.AUTH.FORGOT_PASSWORD}
component={Link}
underline="never"
size="sm"
>
{t("Forgot your password?")}
</Anchor>
</Group>
<Button type="submit" fullWidth mt="md" loading={isLoading}>
{t("Sign In")}
</Button>
</form>
</>
)}
</Box>
</Container>
);

View File

@ -37,8 +37,8 @@ export function PasswordResetForm({ resetToken }: PasswordResetFormProps) {
}
return (
<Container size={420} my={40} className={classes.container}>
<Box p="xl" mt={200}>
<Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md">
{t("Password reset")}
</Title>

View File

@ -1,6 +1,5 @@
import * as React from "react";
import * as z from "zod";
import { useForm, zodResolver } from "@mantine/form";
import {
Container,
@ -9,11 +8,17 @@ import {
Button,
PasswordInput,
Box,
Anchor,
Text,
} from "@mantine/core";
import { ISetupWorkspace } from "@/features/auth/types/auth.types";
import useAuth from "@/features/auth/hooks/use-auth";
import classes from "@/features/auth/components/auth.module.css";
import { useTranslation } from "react-i18next";
import SsoCloudSignup from "@/ee/components/sso-cloud-signup.tsx";
import { isCloud } from "@/lib/config.ts";
import { Link } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route.ts";
const formSchema = z.object({
workspaceName: z.string().trim().min(3).max(50),
@ -45,55 +50,71 @@ export function SetupWorkspaceForm() {
}
return (
<Container size={420} my={40} className={classes.container}>
<Box p="xl" mt={200}>
<Title order={2} ta="center" fw={500} mb="md">
{t("Create workspace")}
</Title>
<div>
<Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md">
{t("Create workspace")}
</Title>
<form onSubmit={form.onSubmit(onSubmit)}>
<TextInput
id="workspaceName"
type="text"
label={t("Workspace Name")}
placeholder={t("e.g ACME Inc")}
variant="filled"
mt="md"
{...form.getInputProps("workspaceName")}
/>
{isCloud() && <SsoCloudSignup />}
<TextInput
id="name"
type="text"
label={t("Your Name")}
placeholder={t("enter your full name")}
variant="filled"
mt="md"
{...form.getInputProps("name")}
/>
<form onSubmit={form.onSubmit(onSubmit)}>
<TextInput
id="workspaceName"
type="text"
label={t("Workspace Name")}
placeholder={t("e.g ACME Inc")}
variant="filled"
mt="md"
{...form.getInputProps("workspaceName")}
/>
<TextInput
id="email"
type="email"
label={t("Your Email")}
placeholder="email@example.com"
variant="filled"
mt="md"
{...form.getInputProps("email")}
/>
<TextInput
id="name"
type="text"
label={t("Your Name")}
placeholder={t("enter your full name")}
variant="filled"
mt="md"
{...form.getInputProps("name")}
/>
<PasswordInput
label={t("Password")}
placeholder={t("Enter a strong password")}
variant="filled"
mt="md"
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
{t("Setup workspace")}
</Button>
</form>
</Box>
</Container>
<TextInput
id="email"
type="email"
label={t("Your Email")}
placeholder="email@example.com"
variant="filled"
mt="md"
{...form.getInputProps("email")}
/>
<PasswordInput
label={t("Password")}
placeholder={t("Enter a strong password")}
variant="filled"
mt="md"
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
{t("Create workspace")}
</Button>
</form>
</Box>
</Container>
{isCloud() && (
<Text ta="center">
{t("Already part of an existing workspace?")}{" "}
<Anchor
component={Link}
to={APP_ROUTE.AUTH.SELECT_WORKSPACE}
fw={500}
>
{t("Sign-in")}
</Anchor>
</Text>
)}
</div>
);
}

View File

@ -19,10 +19,15 @@ import {
} from "@/features/auth/types/auth.types";
import { notifications } from "@mantine/notifications";
import { IAcceptInvite } from "@/features/workspace/types/workspace.types.ts";
import { acceptInvitation } from "@/features/workspace/services/workspace-service.ts";
import {
acceptInvitation,
createWorkspace,
} from "@/features/workspace/services/workspace-service.ts";
import APP_ROUTE from "@/lib/app-route.ts";
import { RESET } from "jotai/utils";
import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts";
import { exchangeTokenRedirectUrl, getHostnameUrl } from "@/ee/utils.ts";
export default function useAuth() {
const { t } = useTranslation();
@ -67,9 +72,21 @@ export default function useAuth() {
setIsLoading(true);
try {
const res = await setupWorkspace(data);
setIsLoading(false);
navigate(APP_ROUTE.HOME);
if (isCloud()) {
const res = await createWorkspace(data);
const hostname = res?.workspace?.hostname;
const exchangeToken = res?.exchangeToken;
if (hostname && exchangeToken) {
window.location.href = exchangeTokenRedirectUrl(
hostname,
exchangeToken,
);
}
} else {
const res = await setupWorkspace(data);
setIsLoading(false);
navigate(APP_ROUTE.HOME);
}
} catch (err) {
setIsLoading(false);
notifications.show({

View File

@ -1,6 +1,7 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { getCollabToken, verifyUserToken } from "../services/auth-service";
import { ICollabToken, IVerifyUserToken } from "../types/auth.types";
import { isAxiosError } from "axios";
export function useVerifyUserTokenQuery(
verify: IVerifyUserToken,
@ -19,7 +20,13 @@ export function useCollabToken(): UseQueryResult<ICollabToken, Error> {
queryFn: () => getCollabToken(),
staleTime: 24 * 60 * 60 * 1000, //24hrs
refetchInterval: 20 * 60 * 60 * 1000, //20hrs
retry: 10,
//@ts-ignore
retry: (failureCount, error) => {
if (isAxiosError(error) && error.response.status === 404) {
return false;
}
return 10;
},
retryDelay: (retryAttempt) => {
// Exponential backoff: 5s, 10s, 20s, etc.
return 5000 * Math.pow(2, retryAttempt - 1);

View File

@ -8,6 +8,7 @@ import {
ISetupWorkspace,
IVerifyUserToken,
} from "@/features/auth/types/auth.types";
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
export async function login(data: ILogin): Promise<void> {
await api.post<void>("/auth/login", data);
@ -26,8 +27,8 @@ export async function changePassword(
export async function setupWorkspace(
data: ISetupWorkspace,
): Promise<any> {
const req = await api.post<any>("/auth/setup", data);
): Promise<IWorkspace> {
const req = await api.post<IWorkspace>("/auth/setup", data);
return req.data;
}