mirror of
https://github.com/docmost/docmost.git
synced 2025-11-26 16:43:40 +10:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
81
apps/client/src/ee/licence/components/license-details.tsx
Normal file
81
apps/client/src/ee/licence/components/license-details.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function LicenseMessage() {
|
||||
return <>To unlock enterprise features, please contact sales@docmost.com to purchase a license.</>;
|
||||
}
|
||||
39
apps/client/src/ee/licence/components/oss-details.tsx
Normal file
39
apps/client/src/ee/licence/components/oss-details.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
apps/client/src/ee/licence/components/remove-license.tsx
Normal file
33
apps/client/src/ee/licence/components/remove-license.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
26
apps/client/src/ee/licence/license.utils.ts
Normal file
26
apps/client/src/ee/licence/license.utils.ts
Normal 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;
|
||||
}
|
||||
35
apps/client/src/ee/licence/pages/license.tsx
Normal file
35
apps/client/src/ee/licence/pages/license.tsx
Normal 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 />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
52
apps/client/src/ee/licence/queries/license-query.ts
Normal file
52
apps/client/src/ee/licence/queries/license-query.ts
Normal 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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
18
apps/client/src/ee/licence/services/license-service.ts
Normal file
18
apps/client/src/ee/licence/services/license-service.ts
Normal 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");
|
||||
}
|
||||
8
apps/client/src/ee/licence/types/license.types.ts
Normal file
8
apps/client/src/ee/licence/types/license.types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface ILicenseInfo {
|
||||
id: string;
|
||||
customerName: string;
|
||||
seatCount: number;
|
||||
issuedAt: Date;
|
||||
expiresAt: Date;
|
||||
trial: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user