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

@@ -0,0 +1,89 @@
import * as z from "zod";
import React from "react";
import { Button, Group, Modal, Textarea } from "@mantine/core";
import { useForm, zodResolver } from "@mantine/form";
import { useTranslation } from "react-i18next";
import { useActivateMutation } from "@/ee/licence/queries/license-query.ts";
import { useDisclosure } from "@mantine/hooks";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import RemoveLicense from "@/ee/licence/components/remove-license.tsx";
export default function ActivateLicense() {
const { t } = useTranslation();
const [opened, { open, close }] = useDisclosure(false);
const [workspace] = useAtom(workspaceAtom);
return (
<Group justify="flex-end" wrap="nowrap" mb="sm">
<Button onClick={open}>
{workspace?.hasLicenseKey ? t("Update license") : t("Add license")}
</Button>
{workspace?.hasLicenseKey && <RemoveLicense />}
<Modal
size="550"
opened={opened}
onClose={close}
title={t("Enterprise license")}
centered
>
<ActivateLicenseForm onClose={close} />
</Modal>
</Group>
);
}
const formSchema = z.object({
licenseKey: z.string().min(1),
});
type FormValues = z.infer<typeof formSchema>;
interface ActivateLicenseFormProps {
onClose?: () => void;
}
export function ActivateLicenseForm({ onClose }: ActivateLicenseFormProps) {
const { t } = useTranslation();
const activateLicenseMutation = useActivateMutation();
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
initialValues: {
licenseKey: "",
},
});
async function handleSubmit(data: { licenseKey: string }) {
await activateLicenseMutation.mutateAsync(data.licenseKey);
form.reset();
onClose();
}
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Textarea
label={t("License key")}
description="Enter a valid enterprise license key. Contact sales@docmost.com to purchase one."
placeholder={t("e.g eyJhb.....")}
variant="filled"
autosize
minRows={3}
maxRows={5}
data-autofocus
{...form.getInputProps("licenseKey")}
/>
<Group justify="flex-end" mt="md">
<Button
type="submit"
disabled={activateLicenseMutation.isPending}
loading={activateLicenseMutation.isPending}
>
{t("Save")}
</Button>
</Group>
</form>
);
}

View File

@@ -0,0 +1,71 @@
import React from "react";
import useUserRole from "@/hooks/use-user-role.tsx";
import classes from "@/ee/billing/components/billing.module.css";
import {
Group,
Paper,
SimpleGrid,
Text,
TextInput,
} from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import CopyTextButton from "@/components/common/copy.tsx";
export default function InstallationDetails() {
const { isAdmin } = useUserRole();
const [workspace] = useAtom(workspaceAtom);
if (!isAdmin) {
return null;
}
return (
<>
<SimpleGrid cols={{ base: 1, xs: 2, sm: 2 }}>
<Paper p="sm" radius="md" withBorder={true}>
<Group justify="apart" grow>
<div>
<Text
c="dimmed"
tt="uppercase"
fw={700}
fz="xs"
className={classes.label}
>
Workspace ID
</Text>
<TextInput
style={{ fontWeight: 700 }}
variant="unstyled"
readOnly
value={workspace?.id}
pointer
rightSection={<CopyTextButton text={workspace?.id} />}
/>
</div>
</Group>
</Paper>
<Paper p="md" radius="md" withBorder={true}>
<Group justify="apart">
<div>
<Text
c="dimmed"
tt="uppercase"
fw={700}
fz="xs"
className={classes.label}
>
Member count
</Text>
<Text fw={700} fz="lg" tt="capitalize">
{workspace?.memberCount}
</Text>
</div>
</Group>
</Paper>
</SimpleGrid>
</>
);
}

View File

@@ -0,0 +1,81 @@
import { Badge, Table } from "@mantine/core";
import { format } from "date-fns";
import { useLicenseInfo } from "@/ee/licence/queries/license-query.ts";
import { isLicenseExpired } from "@/ee/licence/license.utils.ts";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
export default function LicenseDetails() {
const { data: license, isError } = useLicenseInfo();
const [workspace] = useAtom(workspaceAtom);
if (!license) {
return null;
}
if (isError) {
return null;
}
return (
<Table.ScrollContainer minWidth={500} py="md">
<Table
variant="vertical"
verticalSpacing="sm"
layout="fixed"
withTableBorder
>
<Table.Caption>
Contact sales@docmost.com for support and enquiries.
</Table.Caption>
<Table.Tbody>
<Table.Tr>
<Table.Th w={160}>Edition</Table.Th>
<Table.Td>
Enterprise {license.trial && <Badge color="green">Trial</Badge>}
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Th>Licensed to</Table.Th>
<Table.Td>{license.customerName}</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Th>Seat count</Table.Th>
<Table.Td>
{license.seatCount} ({workspace?.memberCount} used)
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Th>Issued at</Table.Th>
<Table.Td>{format(license.issuedAt, "dd MMMM, yyyy")}</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Th>Expires at</Table.Th>
<Table.Td>{format(license.expiresAt, "dd MMMM, yyyy")}</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Th>License ID</Table.Th>
<Table.Td>{license.id}</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Th>Status</Table.Th>
<Table.Td>
{isLicenseExpired(license) ? (
<Badge color="red" variant="light">
Expired
</Badge>
) : (
<Badge color="blue" variant="light">
Valid
</Badge>
)}
</Table.Td>
</Table.Tr>
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}

View File

@@ -0,0 +1,3 @@
export default function LicenseMessage() {
return <>To unlock enterprise features, please contact sales@docmost.com to purchase a license.</>;
}

View File

@@ -0,0 +1,39 @@
import { Group, Table, ThemeIcon } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
export default function OssDetails() {
return (
<Table.ScrollContainer minWidth={500} py="md">
<Table
variant="vertical"
verticalSpacing="sm"
layout="fixed"
withTableBorder
>
<Table.Caption>
To unlock enterprise features like SSO, contact sales@docmost.com.
</Table.Caption>
<Table.Tbody>
<Table.Tr>
<Table.Th w={160}>Edition</Table.Th>
<Table.Td>
<Group wrap="nowrap">
Open Source
<div>
<ThemeIcon
color="green"
variant="light"
size={24}
radius="xl"
>
<IconCheck size={16} />
</ThemeIcon>
</div>
</Group>
</Table.Td>
</Table.Tr>
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}

View File

@@ -0,0 +1,33 @@
import { useTranslation } from "react-i18next";
import { useRemoveLicenseMutation } from "@/ee/licence/queries/license-query.ts";
import { Button, Group, Text } from "@mantine/core";
import { modals } from "@mantine/modals";
import React from "react";
export default function RemoveLicense() {
const { t } = useTranslation();
const removeLicenseMutation = useRemoveLicenseMutation();
const openDeleteModal = () =>
modals.openConfirmModal({
title: t("Remove license key"),
centered: true,
children: (
<Text size="sm">
{t(
"Are you sure you want to remove your license key? Your workspace will be downgraded to the non-enterprise version.",
)}
</Text>
),
labels: { confirm: t("Remove"), cancel: t("Don't") },
confirmProps: { color: "red" },
onConfirm: () => removeLicenseMutation.mutate(),
});
return (
<Group>
<Button variant="light" color="red" onClick={openDeleteModal}>Remove license</Button>
</Group>
);
}

View File

@@ -0,0 +1,26 @@
import { ILicenseInfo } from "@/ee/licence/types/license.types.ts";
import { differenceInDays, isAfter } from "date-fns";
export const GRACE_PERIOD_DAYS = 10;
export function isLicenseExpired(license: ILicenseInfo): boolean {
return isAfter(new Date(), license.expiresAt);
}
export function daysToExpire(license: ILicenseInfo): number {
const days = differenceInDays(license.expiresAt, new Date());
return days > 0 ? days : 0;
}
export function isTrial(license: ILicenseInfo): boolean {
return license.trial;
}
export function isValid(license: ILicenseInfo): boolean {
return !isLicenseExpired(license);
}
export function hasExpiredGracePeriod(license: ILicenseInfo): boolean {
if (!isLicenseExpired(license)) return false;
return differenceInDays(new Date(), license.expiresAt) > GRACE_PERIOD_DAYS;
}

View File

@@ -0,0 +1,35 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import React from "react";
import useUserRole from "@/hooks/use-user-role.tsx";
import LicenseDetails from "@/ee/licence/components/license-details.tsx";
import ActivateLicenseForm from "@/ee/licence/components/activate-license-modal.tsx";
import InstallationDetails from "@/ee/licence/components/installation-details.tsx";
import OssDetails from "@/ee/licence/components/oss-details.tsx";
import { useAtom } from "jotai/index";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
export default function License() {
const [workspace] = useAtom(workspaceAtom);
const { isAdmin } = useUserRole();
if (!isAdmin) {
return null;
}
return (
<>
<Helmet>
<title>License - {getAppName()}</title>
</Helmet>
<SettingsTitle title="License" />
<ActivateLicenseForm />
<InstallationDetails />
{workspace?.hasLicenseKey ? <LicenseDetails /> : <OssDetails />}
</>
);
}

View File

@@ -0,0 +1,52 @@
import {
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
activateLicense,
removeLicense,
getLicenseInfo,
} from "@/ee/licence/services/license-service.ts";
import { ILicenseInfo } from "@/ee/licence/types/license.types.ts";
import { notifications } from "@mantine/notifications";
export function useLicenseInfo(): UseQueryResult<ILicenseInfo, Error> {
return useQuery({
queryKey: ["license"],
queryFn: () => getLicenseInfo(),
staleTime: 5 * 60 * 1000,
});
}
export function useActivateMutation() {
const queryClient = useQueryClient();
return useMutation<ILicenseInfo, Error, string>({
mutationFn: (licenseKey) => activateLicense(licenseKey),
onSuccess: () => {
notifications.show({ message: "License activated successfully" });
queryClient.refetchQueries({
queryKey: ["license"],
});
queryClient.refetchQueries({ queryKey: ["currentUser"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useRemoveLicenseMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => removeLicense(),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["license"] });
queryClient.refetchQueries({ queryKey: ["currentUser"] });
},
});
}

View File

@@ -0,0 +1,18 @@
import api from "@/lib/api-client.ts";
import { ILicenseInfo } from "@/ee/licence/types/license.types.ts";
export async function getLicenseInfo(): Promise<ILicenseInfo> {
const req = await api.post<ILicenseInfo>("/license/info");
return req.data;
}
export async function activateLicense(
licenseKey: string,
): Promise<ILicenseInfo> {
const req = await api.post<ILicenseInfo>("/license/activate", { licenseKey });
return req.data;
}
export async function removeLicense(): Promise<void> {
await api.post<void>("/license/remove");
}

View File

@@ -0,0 +1,8 @@
export interface ILicenseInfo {
id: string;
customerName: string;
seatCount: number;
issuedAt: Date;
expiresAt: Date;
trial: boolean;
}