Compare commits

...

10 Commits

Author SHA1 Message Date
Philipinho 62a2eb61ea custom domain support (cloud) 2025-06-28 19:05:03 -07:00
Philipinho 232cea8cc9 sync 2025-06-27 03:20:01 -07:00
Philipinho b9643d3584 sync 2025-06-27 03:07:51 -07:00
Philip Okugbe 9f144d35fb posthog integration (cloud) (#1304) 2025-06-27 10:58:36 +01:00
Philip Okugbe e44c170873 fix editor flickers on collab reconnection (#1295)
* fix editor flickers on reconnection

* cleanup

* adjust copy
2025-06-27 10:58:18 +01:00
Philipinho 1be39d4353 sync 2025-06-27 02:22:11 -07:00
Philipinho 36d028ef4d sync 2025-06-24 05:53:59 -07:00
Philip Okugbe f5a36c60e8 feat: tiered billing (cloud) (#1294)
* feat: tiered billing (cloud)

* custom tier
2025-06-24 13:22:38 +01:00
Finn Dittmar d5b84ae0b8 Only allow changing the email if the correct password is provided (#1288)
* fix

* fix overwriting password

* finalize

* BadRequestException

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2025-06-24 09:02:55 +01:00
Philip Okugbe e775e4dd8c fix(editor): prevent text color removal from other list items when setting color in lists (#1289)
Only unset color when 'Default' is selected. This ensures setting color on one list item does not remove it from others.
2025-06-23 19:31:30 +01:00
28 changed files with 614 additions and 221 deletions
+1
View File
@@ -41,6 +41,7 @@
"lowlight": "^3.3.0", "lowlight": "^3.3.0",
"mermaid": "^11.6.0", "mermaid": "^11.6.0",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"posthog-js": "^1.255.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-arborist": "3.4.0", "react-arborist": "3.4.0",
"react-clear-modal": "^2.0.15", "react-clear-modal": "^2.0.15",
@@ -1,6 +1,8 @@
import { UserProvider } from "@/features/user/user-provider.tsx"; import { UserProvider } from "@/features/user/user-provider.tsx";
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx"; import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
import { PosthogUser } from "@/ee/components/posthog-user.tsx";
import { isCloud } from "@/lib/config.ts";
export default function Layout() { export default function Layout() {
return ( return (
@@ -8,6 +10,7 @@ export default function Layout() {
<GlobalAppShell> <GlobalAppShell>
<Outlet /> <Outlet />
</GlobalAppShell> </GlobalAppShell>
{isCloud() && <PosthogUser />}
</UserProvider> </UserProvider>
); );
} }
@@ -30,12 +30,12 @@ export default function BillingDetails() {
> >
Plan Plan
</Text> </Text>
<Text fw={700} fz="lg"> <Text fw={700} fz="lg" tt="capitalize">
{ {plans.find(
plans.find( (plan) => plan.productId === billing.stripeProductId,
(plan) => plan.productId === billing.stripeProductId, )?.name ||
)?.name billing.planName ||
} "Standard"}
</Text> </Text>
</div> </div>
</Group> </Group>
@@ -112,18 +112,58 @@ export default function BillingDetails() {
fz="xs" fz="xs"
className={classes.label} className={classes.label}
> >
Total Cost
</Text>
<Text fw={700} fz="lg">
{(billing.amount / 100) * billing.quantity}{" "}
{billing.currency.toUpperCase()}
</Text>
<Text c="dimmed" fz="sm">
${billing.amount / 100} /user/{billing.interval}
</Text> </Text>
{billing.billingScheme === "tiered" && (
<>
<Text fw={700} fz="lg">
${billing.amount / 100} {billing.currency.toUpperCase()}
</Text>
<Text c="dimmed" fz="sm">
per {billing.interval}
</Text>
</>
)}
{billing.billingScheme !== "tiered" && (
<>
<Text fw={700} fz="lg">
{(billing.amount / 100) * billing.quantity}{" "}
{billing.currency.toUpperCase()}
</Text>
<Text c="dimmed" fz="sm">
${billing.amount / 100} /user/{billing.interval}
</Text>
</>
)}
</div> </div>
</Group> </Group>
</Paper> </Paper>
{billing.billingScheme === "tiered" && billing.tieredUpTo && (
<Paper p="md" radius="md">
<Group justify="apart">
<div>
<Text
c="dimmed"
tt="uppercase"
fw={700}
fz="xs"
className={classes.label}
>
Current Tier
</Text>
<Text fw={700} fz="lg">
For {billing.tieredUpTo} users
</Text>
{/*billing.tieredFlatAmount && (
<Text c="dimmed" fz="sm">
</Text>
)*/}
</div>
</Group>
</Paper>
)}
</SimpleGrid> </SimpleGrid>
</div> </div>
); );
@@ -2,24 +2,28 @@ import {
Button, Button,
Card, Card,
List, List,
SegmentedControl,
ThemeIcon, ThemeIcon,
Title, Title,
Text, Text,
Group, Group,
Select,
Container,
Stack,
Badge,
Flex,
Switch,
} from "@mantine/core"; } from "@mantine/core";
import { useState } from "react"; import { useState } from "react";
import { IconCheck } from "@tabler/icons-react"; import { IconCheck } from "@tabler/icons-react";
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts"; import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
export default function BillingPlans() { export default function BillingPlans() {
const { data: plans } = useBillingPlans(); const { data: plans } = useBillingPlans();
const [interval, setInterval] = useState("yearly"); const [isAnnual, setIsAnnual] = useState(true);
const [selectedTierValue, setSelectedTierValue] = useState<string | null>(
if (!plans) { null,
return null; );
}
const handleCheckout = async (priceId: string) => { const handleCheckout = async (priceId: string) => {
try { try {
@@ -32,84 +36,153 @@ export default function BillingPlans() {
} }
}; };
if (!plans || plans.length === 0) {
return null;
}
const firstPlan = plans[0];
// Set initial tier value if not set
if (!selectedTierValue && firstPlan.pricingTiers.length > 0) {
setSelectedTierValue(firstPlan.pricingTiers[0].upTo.toString());
return null;
}
if (!selectedTierValue) {
return null;
}
const selectData = firstPlan.pricingTiers
.filter((tier) => !tier.custom)
.map((tier, index) => {
const prevMaxUsers =
index > 0 ? firstPlan.pricingTiers[index - 1].upTo : 0;
return {
value: tier.upTo.toString(),
label: `${prevMaxUsers + 1}-${tier.upTo} users`,
};
});
return ( return (
<Group justify="center" p="xl"> <Container size="xl" py="xl">
{plans.map((plan) => { {/* Controls Section */}
const price = <Stack gap="xl" mb="md">
interval === "monthly" ? plan.price.monthly : plan.price.yearly; {/* Team Size and Billing Controls */}
const priceId = interval === "monthly" ? plan.monthlyId : plan.yearlyId; <Group justify="center" align="center" gap="sm">
const yearlyMonthPrice = parseInt(plan.price.yearly) / 12; <Select
label="Team size"
description="Select the number of users"
value={selectedTierValue}
onChange={setSelectedTierValue}
data={selectData}
w={250}
size="md"
allowDeselect={false}
/>
return ( <Group justify="center" align="start">
<Card <Flex justify="center" gap="md" align="center">
key={plan.name} <Text size="md">Monthly</Text>
withBorder <Switch
radius="md" defaultChecked={isAnnual}
shadow="sm" onChange={(event) => setIsAnnual(event.target.checked)}
p="xl"
w={300}
>
<SegmentedControl
value={interval}
onChange={setInterval}
fullWidth
data={[
{ label: "Monthly", value: "monthly" },
{ label: "Yearly (25% OFF)", value: "yearly" },
]}
/>
<Title order={3} ta="center" mt="sm" mb="xs">
{plan.name}
</Title>
<Text ta="center" size="lg" fw={700}>
{interval === "monthly" && (
<>
${price}{" "}
<Text span size="sm" fw={500} c="dimmed">
/user/month
</Text>
</>
)}
{interval === "yearly" && (
<>
${yearlyMonthPrice}{" "}
<Text span size="sm" fw={500} c="dimmed">
/user/month
</Text>
</>
)}
<br/>
<Text span ta="center" size="md" fw={500} c="dimmed">
billed {interval}
</Text>
</Text>
<Card.Section mt="lg">
<Button onClick={() => handleCheckout(priceId)} fullWidth>
Subscribe
</Button>
</Card.Section>
<Card.Section mt="md">
<List
spacing="xs"
size="sm" size="sm"
center />
icon={ <Text size="md">
<ThemeIcon variant="light" size={24} radius="xl"> Annually
<IconCheck size={16} /> <Badge component="span" variant="light" color="blue">
</ThemeIcon> 15% OFF
} </Badge>
> </Text>
{plan.features.map((feature, index) => ( </Flex>
<List.Item key={index}>{feature}</List.Item> </Group>
))} </Group>
</List> </Stack>
</Card.Section>
</Card> {/* Plans Grid */}
); <Group justify="center" gap="lg" align="stretch">
})} {plans.map((plan, index) => {
</Group> const tieredPlan = plan;
const planSelectedTier =
tieredPlan.pricingTiers.find(
(tier) => tier.upTo.toString() === selectedTierValue,
) || tieredPlan.pricingTiers[0];
const price = isAnnual
? planSelectedTier.yearly
: planSelectedTier.monthly;
const priceId = isAnnual ? plan.yearlyId : plan.monthlyId;
return (
<Card
key={plan.name}
withBorder
radius="lg"
shadow="sm"
p="xl"
w={350}
miw={300}
style={{
position: "relative",
}}
>
<Stack gap="lg">
{/* Plan Header */}
<Stack gap="xs">
<Title order={3} size="h4">
{plan.name}
</Title>
{plan.description && (
<Text size="sm" c="dimmed">
{plan.description}
</Text>
)}
</Stack>
{/* Pricing */}
<Stack gap="xs">
<Group align="baseline" gap="xs">
<Title order={1} size="h1">
${isAnnual ? (price / 12).toFixed(0) : price}
</Title>
<Text size="lg" c="dimmed">
per {isAnnual ? "month" : "month"}
</Text>
</Group>
{isAnnual && (
<Text size="sm" c="dimmed">
Billed annually
</Text>
)}
<Text size="md" fw={500}>
For {planSelectedTier.upTo} users
</Text>
</Stack>
{/* CTA Button */}
<Button onClick={() => handleCheckout(priceId)} fullWidth>
Upgrade
</Button>
{/* Features */}
<List
spacing="xs"
size="sm"
icon={
<ThemeIcon size={20} radius="xl">
<IconCheck size={14} />
</ThemeIcon>
}
>
{plan.features.map((feature, featureIndex) => (
<List.Item key={featureIndex}>{feature}</List.Item>
))}
</List>
</Stack>
</Card>
);
})}
</Group>
</Container>
); );
} }
@@ -25,6 +25,11 @@ export interface IBilling {
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
deletedAt: Date; deletedAt: Date;
billingScheme: string | null;
tieredUpTo: string | null;
tieredFlatAmount: number | null;
tieredUnitAmount: number | null;
planName: string | null;
} }
export interface ICheckoutLink { export interface ICheckoutLink {
@@ -42,9 +47,18 @@ export interface IBillingPlan {
monthlyId: string; monthlyId: string;
yearlyId: string; yearlyId: string;
currency: string; currency: string;
price: { price?: {
monthly: string; monthly: string;
yearly: string; yearly: string;
}; };
features: string[]; features: string[];
billingScheme: string | null;
pricingTiers: PricingTier[];
} }
interface PricingTier {
upTo: number;
monthly?: number;
yearly?: number;
custom?: boolean;
}
@@ -0,0 +1,41 @@
import { usePostHog } from "posthog-js/react";
import { useEffect } from "react";
import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
export function PosthogUser() {
const posthog = usePostHog();
const [currentUser] = useAtom(currentUserAtom);
useEffect(() => {
if (currentUser) {
const user = currentUser?.user;
const workspace = currentUser?.workspace;
if (!user || !workspace) return;
posthog?.identify(user.id, {
name: user.name,
email: user.email,
workspaceId: user.workspaceId,
workspaceHostname: workspace.hostname,
lastActiveAt: new Date().toISOString(),
createdAt: user.createdAt,
source: "docmost-app",
});
posthog?.group("workspace", workspace.id, {
name: workspace.name,
hostname: workspace.hostname,
plan: workspace?.plan,
status: workspace.status,
isOnTrial: !!workspace.trialEndAt,
hasStripeCustomerId: !!workspace.stripeCustomerId,
memberCount: workspace.memberCount,
lastActiveAt: new Date().toISOString(),
createdAt: workspace.createdAt,
source: "docmost-app",
});
}
}, [posthog, currentUser]);
return null;
}
@@ -156,13 +156,11 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
) )
} }
onClick={() => { onClick={() => {
editor.commands.unsetColor(); if (name === "Default") {
name !== "Default" && editor.commands.unsetColor();
editor } else {
.chain() editor.chain().focus().setColor(color || "").run();
.focus() }
.setColor(color || "")
.run();
setIsOpen(false); setIsOpen(false);
}} }}
style={{ border: "none" }} style={{ border: "none" }}
+125 -93
View File
@@ -1,7 +1,6 @@
import "@/features/editor/styles/index.css"; import "@/features/editor/styles/index.css";
import React, { import React, {
useEffect, useEffect,
useLayoutEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
@@ -72,7 +71,11 @@ export default function PageEditor({
const [, setAsideState] = useAtom(asideStateAtom); const [, setAsideState] = useAtom(asideStateAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom); const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom); const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const ydoc = useMemo(() => new Y.Doc(), [pageId]); const ydocRef = useRef<Y.Doc | null>(null);
if (!ydocRef.current) {
ydocRef.current = new Y.Doc();
}
const ydoc = ydocRef.current;
const [isLocalSynced, setLocalSynced] = useState(false); const [isLocalSynced, setLocalSynced] = useState(false);
const [isRemoteSynced, setRemoteSynced] = useState(false); const [isRemoteSynced, setRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom( const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
@@ -89,66 +92,100 @@ export default function PageEditor({
const userPageEditMode = const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit; currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const localProvider = useMemo(() => { // Providers only created once per pageId
const provider = new IndexeddbPersistence(documentName, ydoc); const providersRef = useRef<{
local: IndexeddbPersistence;
remote: HocuspocusProvider;
} | null>(null);
const [providersReady, setProvidersReady] = useState(false);
provider.on("synced", () => { const localProvider = providersRef.current?.local;
setLocalSynced(true); const remoteProvider = providersRef.current?.remote;
});
return provider; // Track when collaborative provider is ready and synced
}, [pageId, ydoc]); const [collabReady, setCollabReady] = useState(false);
useEffect(() => {
if (
remoteProvider?.status === WebSocketStatus.Connected &&
isLocalSynced &&
isRemoteSynced
) {
setCollabReady(true);
}
}, [remoteProvider?.status, isLocalSynced, isRemoteSynced]);
const remoteProvider = useMemo(() => { useEffect(() => {
const provider = new HocuspocusProvider({ if (!providersRef.current) {
name: documentName, const local = new IndexeddbPersistence(documentName, ydoc);
url: collaborationURL, local.on("synced", () => setLocalSynced(true));
document: ydoc, const remote = new HocuspocusProvider({
token: collabQuery?.token, name: documentName,
connect: false, url: collaborationURL,
preserveConnection: false, document: ydoc,
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => { token: collabQuery?.token,
const payload = jwtDecode(collabQuery?.token); connect: true,
const now = Date.now().valueOf() / 1000; preserveConnection: false,
const isTokenExpired = now >= payload.exp; onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => {
if (isTokenExpired) { const payload = jwtDecode(collabQuery?.token);
refetchCollabToken(); const now = Date.now().valueOf() / 1000;
} const isTokenExpired = now >= payload.exp;
}, if (isTokenExpired) {
onStatus: (status) => { refetchCollabToken();
if (status.status === "connected") { }
setYjsConnectionStatus(status.status); },
} onStatus: (status) => {
}, if (status.status === "connected") {
}); setYjsConnectionStatus(status.status);
}
provider.on("synced", () => { },
setRemoteSynced(true); });
}); remote.on("synced", () => setRemoteSynced(true));
remote.on("disconnect", () => {
provider.on("disconnect", () => { setYjsConnectionStatus(WebSocketStatus.Disconnected);
setYjsConnectionStatus(WebSocketStatus.Disconnected); });
}); providersRef.current = { local, remote };
setProvidersReady(true);
return provider; } else {
}, [ydoc, pageId, collabQuery?.token]); setProvidersReady(true);
}
useLayoutEffect(() => { // Only destroy on final unmount
remoteProvider.connect();
return () => { return () => {
setRemoteSynced(false); providersRef.current?.remote.destroy();
setLocalSynced(false); providersRef.current?.local.destroy();
remoteProvider.destroy(); providersRef.current = null;
localProvider.destroy();
}; };
}, [remoteProvider, localProvider]); }, [pageId]);
// Only connect/disconnect on tab/idle, not destroy
useEffect(() => {
if (!providersReady || !providersRef.current) return;
const remoteProvider = providersRef.current.remote;
if (
isIdle &&
documentState === "hidden" &&
remoteProvider.status === WebSocketStatus.Connected
) {
remoteProvider.disconnect();
setIsCollabReady(false);
return;
}
if (
documentState === "visible" &&
remoteProvider.status === WebSocketStatus.Disconnected
) {
resetIdle();
remoteProvider.connect();
setTimeout(() => setIsCollabReady(true), 500);
}
}, [isIdle, documentState, providersReady, resetIdle]);
const extensions = useMemo(() => { const extensions = useMemo(() => {
if (!remoteProvider || !currentUser?.user) return mainExtensions;
return [ return [
...mainExtensions, ...mainExtensions,
...collabExtensions(remoteProvider, currentUser?.user), ...collabExtensions(remoteProvider, currentUser?.user),
]; ];
}, [ydoc, pageId, remoteProvider, currentUser?.user]); }, [remoteProvider, currentUser?.user]);
const editor = useEditor( const editor = useEditor(
{ {
@@ -202,7 +239,7 @@ export default function PageEditor({
debouncedUpdateContent(editorJson); debouncedUpdateContent(editorJson);
}, },
}, },
[pageId, editable, remoteProvider?.status], [pageId, editable, remoteProvider],
); );
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => { const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
@@ -255,29 +292,6 @@ export default function PageEditor({
} }
}, [remoteProvider?.status]); }, [remoteProvider?.status]);
useEffect(() => {
if (
isIdle &&
documentState === "hidden" &&
remoteProvider?.status === WebSocketStatus.Connected
) {
remoteProvider.disconnect();
setIsCollabReady(false);
return;
}
if (
documentState === "visible" &&
remoteProvider?.status === WebSocketStatus.Disconnected
) {
resetIdle();
remoteProvider.connect();
setTimeout(() => {
setIsCollabReady(true);
}, 600);
}
}, [isIdle, documentState, remoteProvider]);
const isSynced = isLocalSynced && isRemoteSynced; const isSynced = isLocalSynced && isRemoteSynced;
useEffect(() => { useEffect(() => {
@@ -294,21 +308,48 @@ export default function PageEditor({
}, [isRemoteSynced, isLocalSynced, remoteProvider?.status]); }, [isRemoteSynced, isLocalSynced, remoteProvider?.status]);
useEffect(() => { useEffect(() => {
// honor user default page edit mode preference // Only honor user default page edit mode preference and permissions
if (userPageEditMode && editor && editable && isSynced) { if (editor) {
if (userPageEditMode === PageEditMode.Edit) { if (userPageEditMode && editable) {
editor.setEditable(true); if (userPageEditMode === PageEditMode.Edit) {
} else if (userPageEditMode === PageEditMode.Read) { editor.setEditable(true);
} else if (userPageEditMode === PageEditMode.Read) {
editor.setEditable(false);
}
} else {
editor.setEditable(false); editor.setEditable(false);
} }
} }
}, [userPageEditMode, editor, editable, isSynced]); }, [userPageEditMode, editor, editable]);
return isCollabReady ? ( const hasConnectedOnceRef = useRef(false);
<div> const [showStatic, setShowStatic] = useState(true);
useEffect(() => {
if (
!hasConnectedOnceRef.current &&
remoteProvider?.status === WebSocketStatus.Connected
) {
hasConnectedOnceRef.current = true;
setShowStatic(false);
}
}, [remoteProvider?.status]);
if (showStatic) {
return (
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={mainExtensions}
content={content}
/>
);
}
return (
<div style={{ position: "relative" }}>
<div ref={menuContainerRef}> <div ref={menuContainerRef}>
<EditorContent editor={editor} /> <EditorContent editor={editor} />
{editor && editor.isEditable && ( {editor && editor.isEditable && (
<div> <div>
<EditorBubbleMenu editor={editor} /> <EditorBubbleMenu editor={editor} />
@@ -322,21 +363,12 @@ export default function PageEditor({
<LinkMenu editor={editor} appendTo={menuContainerRef} /> <LinkMenu editor={editor} appendTo={menuContainerRef} />
</div> </div>
)} )}
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />} {showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
</div> </div>
<div <div
onClick={() => editor.commands.focus("end")} onClick={() => editor.commands.focus("end")}
style={{ paddingBottom: "20vh" }} style={{ paddingBottom: "20vh" }}
></div> ></div>
</div> </div>
) : (
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={mainExtensions}
content={content}
></EditorProvider>
); );
} }
@@ -12,6 +12,7 @@ export interface IWorkspace {
settings: any; settings: any;
status: string; status: string;
enforceSso: boolean; enforceSso: boolean;
stripeCustomerId: string;
billingEmail: string; billingEmail: string;
trialEndAt: Date; trialEndAt: Date;
createdAt: Date; createdAt: Date;
+12
View File
@@ -83,6 +83,18 @@ export function getBillingTrialDays() {
return getConfigValue("BILLING_TRIAL_DAYS"); return getConfigValue("BILLING_TRIAL_DAYS");
} }
export function getPostHogHost() {
return getConfigValue("POSTHOG_HOST");
}
export function isPostHogEnabled(): boolean {
return Boolean(getPostHogHost() && getPostHogKey());
}
export function getPostHogKey() {
return getConfigValue("POSTHOG_KEY");
}
function getConfigValue(key: string, defaultValue: string = undefined): string { function getConfigValue(key: string, defaultValue: string = undefined): string {
const rawValue = import.meta.env.DEV const rawValue = import.meta.env.DEV
? process?.env?.[key] ? process?.env?.[key]
+21 -4
View File
@@ -3,7 +3,7 @@ import "@mantine/spotlight/styles.css";
import "@mantine/notifications/styles.css"; import "@mantine/notifications/styles.css";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App.tsx"; import App from "./App.tsx";
import { mantineCssResolver, theme } from '@/theme'; import { mantineCssResolver, theme } from "@/theme";
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import { ModalsProvider } from "@mantine/modals"; import { ModalsProvider } from "@mantine/modals";
@@ -11,6 +11,14 @@ import { Notifications } from "@mantine/notifications";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { HelmetProvider } from "react-helmet-async"; import { HelmetProvider } from "react-helmet-async";
import "./i18n"; import "./i18n";
import { PostHogProvider } from "posthog-js/react";
import {
getPostHogHost,
getPostHogKey,
isCloud,
isPostHogEnabled,
} from "@/lib/config.ts";
import posthog from "posthog-js";
export const queryClient = new QueryClient({ export const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@@ -23,9 +31,16 @@ export const queryClient = new QueryClient({
}, },
}); });
if (isCloud() && isPostHogEnabled) {
posthog.init(getPostHogKey(), {
api_host: getPostHogHost(),
defaults: "2025-05-24",
disable_session_recording: true,
});
}
const root = ReactDOM.createRoot( const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement document.getElementById("root") as HTMLElement,
); );
root.render( root.render(
@@ -35,10 +50,12 @@ root.render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Notifications position="bottom-center" limit={3} /> <Notifications position="bottom-center" limit={3} />
<HelmetProvider> <HelmetProvider>
<App /> <PostHogProvider client={posthog}>
<App />
</PostHogProvider>
</HelmetProvider> </HelmetProvider>
</QueryClientProvider> </QueryClientProvider>
</ModalsProvider> </ModalsProvider>
</MantineProvider> </MantineProvider>
</BrowserRouter> </BrowserRouter>,
); );
+4
View File
@@ -14,6 +14,8 @@ export default defineConfig(({ mode }) => {
SUBDOMAIN_HOST, SUBDOMAIN_HOST,
COLLAB_URL, COLLAB_URL,
BILLING_TRIAL_DAYS, BILLING_TRIAL_DAYS,
POSTHOG_HOST,
POSTHOG_KEY,
} = loadEnv(mode, envPath, ""); } = loadEnv(mode, envPath, "");
return { return {
@@ -27,6 +29,8 @@ export default defineConfig(({ mode }) => {
SUBDOMAIN_HOST, SUBDOMAIN_HOST,
COLLAB_URL, COLLAB_URL,
BILLING_TRIAL_DAYS, BILLING_TRIAL_DAYS,
POSTHOG_HOST,
POSTHOG_KEY,
}, },
APP_VERSION: JSON.stringify(process.env.npm_package_version), APP_VERSION: JSON.stringify(process.env.npm_package_version),
}, },
+1
View File
@@ -82,6 +82,7 @@
"sanitize-filename-ts": "^1.0.2", "sanitize-filename-ts": "^1.0.2",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"stripe": "^17.5.0", "stripe": "^17.5.0",
"tld-extract": "^2.1.0",
"tmp-promise": "^3.0.3", "tmp-promise": "^3.0.3",
"ws": "^8.18.2", "ws": "^8.18.2",
"yauzl": "^3.2.0" "yauzl": "^3.2.0"
@@ -1,4 +1,4 @@
import { Injectable, NestMiddleware, NotFoundException } from '@nestjs/common'; import { Injectable, NestMiddleware } from '@nestjs/common';
import { FastifyRequest, FastifyReply } from 'fastify'; import { FastifyRequest, FastifyReply } from 'fastify';
import { EnvironmentService } from '../../integrations/environment/environment.service'; import { EnvironmentService } from '../../integrations/environment/environment.service';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
@@ -27,8 +27,19 @@ export class DomainMiddleware implements NestMiddleware {
(req as any).workspace = workspace; (req as any).workspace = workspace;
} else if (this.environmentService.isCloud()) { } else if (this.environmentService.isCloud()) {
const header = req.headers.host; const header = req.headers.host;
const subdomain = header.split('.')[0];
// First, try to find workspace by custom domain
const workspaceByCustomDomain =
await this.workspaceRepo.findByCustomDomain(header);
if (workspaceByCustomDomain) {
(req as any).workspaceId = workspaceByCustomDomain.id;
(req as any).workspace = workspaceByCustomDomain;
return next();
}
// Fall back to subdomain logic
const subdomain = header.split('.')[0];
const workspace = await this.workspaceRepo.findByHostname(subdomain); const workspace = await this.workspaceRepo.findByHostname(subdomain);
if (!workspace) { if (!workspace) {
@@ -134,7 +134,7 @@ export class AuthService {
const token = nanoIdGen(16); const token = nanoIdGen(16);
const resetLink = `${this.domainService.getUrl(workspace.hostname)}/password-reset?token=${token}`; const resetLink = `${this.domainService.getUrl(workspace.hostname, workspace.customDomain)}/password-reset?token=${token}`;
await this.userTokenRepo.insertUserToken({ await this.userTokenRepo.insertUserToken({
token: token, token: token,
@@ -1,5 +1,13 @@
import { OmitType, PartialType } from '@nestjs/mapped-types'; import { OmitType, PartialType } from '@nestjs/mapped-types';
import { IsBoolean, IsIn, IsOptional, IsString } from 'class-validator'; import {
IsBoolean,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
import { CreateUserDto } from '../../auth/dto/create-user.dto'; import { CreateUserDto } from '../../auth/dto/create-user.dto';
export class UpdateUserDto extends PartialType( export class UpdateUserDto extends PartialType(
@@ -21,4 +29,10 @@ export class UpdateUserDto extends PartialType(
@IsOptional() @IsOptional()
@IsString() @IsString()
locale: string; locale: string;
@IsOptional()
@MinLength(8)
@MaxLength(70)
@IsString()
confirmPassword: string;
} }
+1 -1
View File
@@ -50,6 +50,6 @@ export class UserController {
@AuthUser() user: User, @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
) { ) {
return this.userService.update(updateUserDto, user.id, workspace.id); return this.userService.update(updateUserDto, user.id, workspace);
} }
} }
+33 -4
View File
@@ -3,8 +3,12 @@ import {
BadRequestException, BadRequestException,
Injectable, Injectable,
NotFoundException, NotFoundException,
UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { UpdateUserDto } from './dto/update-user.dto'; import { UpdateUserDto } from './dto/update-user.dto';
import { comparePasswordHash } from 'src/common/helpers/utils';
import { Workspace } from '@docmost/db/types/entity.types';
import { validateSsoEnforcement } from '../auth/auth.util';
@Injectable() @Injectable()
export class UserService { export class UserService {
@@ -17,9 +21,14 @@ export class UserService {
async update( async update(
updateUserDto: UpdateUserDto, updateUserDto: UpdateUserDto,
userId: string, userId: string,
workspaceId: string, workspace: Workspace,
) { ) {
const user = await this.userRepo.findById(userId, workspaceId); const includePassword =
updateUserDto.email != null && updateUserDto.confirmPassword != null;
const user = await this.userRepo.findById(userId, workspace.id, {
includePassword,
});
if (!user) { if (!user) {
throw new NotFoundException('User not found'); throw new NotFoundException('User not found');
@@ -47,9 +56,27 @@ export class UserService {
} }
if (updateUserDto.email && user.email != updateUserDto.email) { if (updateUserDto.email && user.email != updateUserDto.email) {
if (await this.userRepo.findByEmail(updateUserDto.email, workspaceId)) { validateSsoEnforcement(workspace);
if (!updateUserDto.confirmPassword) {
throw new BadRequestException(
'You must provide a password to change your email',
);
}
const isPasswordMatch = await comparePasswordHash(
updateUserDto.confirmPassword,
user.password,
);
if (!isPasswordMatch) {
throw new BadRequestException('You must provide the correct password to change your email');
}
if (await this.userRepo.findByEmail(updateUserDto.email, workspace.id)) {
throw new BadRequestException('A user with this email already exists'); throw new BadRequestException('A user with this email already exists');
} }
user.email = updateUserDto.email; user.email = updateUserDto.email;
} }
@@ -61,7 +88,9 @@ export class UserService {
user.locale = updateUserDto.locale; user.locale = updateUserDto.locale;
} }
await this.userRepo.updateUser(updateUserDto, userId, workspaceId); delete updateUserDto.confirmPassword;
await this.userRepo.updateUser(updateUserDto, userId, workspace.id);
return user; return user;
} }
} }
@@ -171,7 +171,7 @@ export class WorkspaceInvitationService {
invitation.email, invitation.email,
invitation.token, invitation.token,
authUser.name, authUser.name,
workspace.hostname, workspace,
); );
}); });
} }
@@ -317,7 +317,7 @@ export class WorkspaceInvitationService {
invitation.email, invitation.email,
invitation.token, invitation.token,
invitedByUser.name, invitedByUser.name,
workspace.hostname, workspace,
); );
} }
@@ -340,17 +340,17 @@ export class WorkspaceInvitationService {
return this.buildInviteLink({ return this.buildInviteLink({
invitationId, invitationId,
inviteToken: token.token, inviteToken: token.token,
hostname: workspace.hostname, workspace: workspace,
}); });
} }
async buildInviteLink(opts: { async buildInviteLink(opts: {
invitationId: string; invitationId: string;
inviteToken: string; inviteToken: string;
hostname?: string; workspace: Workspace;
}): Promise<string> { }): Promise<string> {
const { invitationId, inviteToken, hostname } = opts; const { invitationId, inviteToken, workspace } = opts;
return `${this.domainService.getUrl(hostname)}/invites/${invitationId}?token=${inviteToken}`; return `${this.domainService.getUrl(workspace.hostname, workspace.customDomain)}/invites/${invitationId}?token=${inviteToken}`;
} }
async sendInvitationMail( async sendInvitationMail(
@@ -358,12 +358,12 @@ export class WorkspaceInvitationService {
inviteeEmail: string, inviteeEmail: string,
inviteToken: string, inviteToken: string,
invitedByName: string, invitedByName: string,
hostname?: string, workspace: Workspace,
): Promise<void> { ): Promise<void> {
const inviteLink = await this.buildInviteLink({ const inviteLink = await this.buildInviteLink({
invitationId, invitationId,
inviteToken, inviteToken,
hostname, workspace,
}); });
const emailTemplate = InvitationEmail({ const emailTemplate = InvitationEmail({
@@ -0,0 +1,23 @@
import { type Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('billing')
.addColumn('billing_scheme', 'varchar', (col) => col)
.addColumn('tiered_up_to', 'varchar', (col) => col)
.addColumn('tiered_flat_amount', 'int8', (col) => col)
.addColumn('tiered_unit_amount', 'int8', (col) => col)
.addColumn('plan_name', 'varchar', (col) => col)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('billing')
.dropColumn('billing_scheme')
.dropColumn('tiered_up_to')
.dropColumn('tiered_flat_amount')
.dropColumn('tiered_unit_amount')
.dropColumn('plan_name')
.execute();
}
@@ -83,6 +83,14 @@ export class WorkspaceRepo {
.executeTakeFirst(); .executeTakeFirst();
} }
async findByCustomDomain(domain: string): Promise<Workspace> {
return await this.db
.selectFrom('workspaces')
.selectAll()
.where(sql`LOWER(custom_domain)`, '=', sql`LOWER(${domain})`)
.executeTakeFirst();
}
async hostnameExists( async hostnameExists(
hostname: string, hostname: string,
trx?: KyselyTransaction, trx?: KyselyTransaction,
+5
View File
@@ -84,6 +84,7 @@ export interface Backlinks {
export interface Billing { export interface Billing {
amount: Int8 | null; amount: Int8 | null;
billingScheme: string | null;
cancelAt: Timestamp | null; cancelAt: Timestamp | null;
cancelAtPeriodEnd: boolean | null; cancelAtPeriodEnd: boolean | null;
canceledAt: Timestamp | null; canceledAt: Timestamp | null;
@@ -96,6 +97,7 @@ export interface Billing {
metadata: Json | null; metadata: Json | null;
periodEndAt: Timestamp | null; periodEndAt: Timestamp | null;
periodStartAt: Timestamp; periodStartAt: Timestamp;
planName: string | null;
quantity: Int8 | null; quantity: Int8 | null;
status: string; status: string;
stripeCustomerId: string | null; stripeCustomerId: string | null;
@@ -103,6 +105,9 @@ export interface Billing {
stripePriceId: string | null; stripePriceId: string | null;
stripeProductId: string | null; stripeProductId: string | null;
stripeSubscriptionId: string; stripeSubscriptionId: string;
tieredFlatAmount: Int8 | null;
tieredUnitAmount: Int8 | null;
tieredUpTo: string | null;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
workspaceId: string; workspaceId: string;
} }
@@ -5,10 +5,13 @@ import { EnvironmentService } from './environment.service';
export class DomainService { export class DomainService {
constructor(private environmentService: EnvironmentService) {} constructor(private environmentService: EnvironmentService) {}
getUrl(hostname?: string): string { getUrl(hostname?: string, customDomain?: string): string {
if (!this.environmentService.isCloud()) { if (!this.environmentService.isCloud()) {
return this.environmentService.getAppUrl(); return this.environmentService.getAppUrl();
} }
if (customDomain) {
return customDomain;
}
const domain = this.environmentService.getSubdomainHost(); const domain = this.environmentService.getSubdomainHost();
if (!hostname || !domain) { if (!hostname || !domain) {
@@ -205,4 +205,12 @@ export class EnvironmentService {
.toLowerCase(); .toLowerCase();
return disable === 'true'; return disable === 'true';
} }
getPostHogHost(): string {
return this.configService.get<string>('POSTHOG_HOST');
}
getPostHogKey(): string {
return this.configService.get<string>('POSTHOG_KEY');
}
} }
@@ -68,6 +68,10 @@ export class EnvironmentVariables {
) )
@ValidateIf((obj) => obj.CLOUD === 'true'.toLowerCase()) @ValidateIf((obj) => obj.CLOUD === 'true'.toLowerCase())
SUBDOMAIN_HOST: string; SUBDOMAIN_HOST: string;
@IsOptional()
@ValidateIf((obj) => obj.CLOUD === 'true'.toLowerCase())
APP_IP: string;
} }
export function validate(config: Record<string, any>) { export function validate(config: Record<string, any>) {
@@ -47,6 +47,8 @@ export class StaticModule implements OnModuleInit {
BILLING_TRIAL_DAYS: this.environmentService.isCloud() BILLING_TRIAL_DAYS: this.environmentService.isCloud()
? this.environmentService.getBillingTrialDays() ? this.environmentService.getBillingTrialDays()
: undefined, : undefined,
POSTHOG_HOST: this.environmentService.getPostHogHost(),
POSTHOG_KEY: this.environmentService.getPostHogKey(),
}; };
const windowScriptContent = `<script>window.CONFIG=${JSON.stringify(configString)};</script>`; const windowScriptContent = `<script>window.CONFIG=${JSON.stringify(configString)};</script>`;
+49
View File
@@ -296,6 +296,9 @@ importers:
mitt: mitt:
specifier: ^3.0.1 specifier: ^3.0.1
version: 3.0.1 version: 3.0.1
posthog-js:
specifier: ^1.255.1
version: 1.255.1
react: react:
specifier: ^18.3.1 specifier: ^18.3.1
version: 18.3.1 version: 18.3.1
@@ -564,6 +567,9 @@ importers:
stripe: stripe:
specifier: ^17.5.0 specifier: ^17.5.0
version: 17.5.0 version: 17.5.0
tld-extract:
specifier: ^2.1.0
version: 2.1.0
tmp-promise: tmp-promise:
specifier: ^3.0.3 specifier: ^3.0.3
version: 3.0.3 version: 3.0.3
@@ -5213,6 +5219,9 @@ packages:
core-js-compat@3.35.0: core-js-compat@3.35.0:
resolution: {integrity: sha512-5blwFAddknKeNgsjBzilkdQ0+YK8L1PfqPYq40NOYMYFSS38qj+hpTcLLWwpIwA2A5bje/x5jmVn2tzUMg9IVw==} resolution: {integrity: sha512-5blwFAddknKeNgsjBzilkdQ0+YK8L1PfqPYq40NOYMYFSS38qj+hpTcLLWwpIwA2A5bje/x5jmVn2tzUMg9IVw==}
core-js@3.43.0:
resolution: {integrity: sha512-N6wEbTTZSYOY2rYAn85CuvWWkCK6QweMn7/4Nr3w+gDBeBhk/x4EJeY6FPo4QzDoJZxVTv8U7CMvgWk6pOHHqA==}
core-util-is@1.0.3: core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
@@ -5988,6 +5997,9 @@ packages:
picomatch: picomatch:
optional: true optional: true
fflate@0.4.8:
resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==}
fflate@0.8.2: fflate@0.8.2:
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
@@ -7955,9 +7967,23 @@ packages:
postgres-range@1.1.4: postgres-range@1.1.4:
resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==}
posthog-js@1.255.1:
resolution: {integrity: sha512-KMh0o9MhORhEZVjXpktXB5rJ8PfDk+poqBoTSoLzWgNjhJf6D8jcyB9jUMA6vVPfn4YeepVX5NuclDRqOwr5Mw==}
peerDependencies:
'@rrweb/types': 2.0.0-alpha.17
rrweb-snapshot: 2.0.0-alpha.17
peerDependenciesMeta:
'@rrweb/types':
optional: true
rrweb-snapshot:
optional: true
postmark@4.0.5: postmark@4.0.5:
resolution: {integrity: sha512-nerZdd3TwOH4CgGboZnlUM/q7oZk0EqpZgJL+Y3Nup8kHeaukxouQ6JcFF3EJEijc4QbuNv1TefGhboAKtf/SQ==} resolution: {integrity: sha512-nerZdd3TwOH4CgGboZnlUM/q7oZk0EqpZgJL+Y3Nup8kHeaukxouQ6JcFF3EJEijc4QbuNv1TefGhboAKtf/SQ==}
preact@10.26.9:
resolution: {integrity: sha512-SSjF9vcnF27mJK1XyFMNJzFd5u3pQiATFqoaDy03XuN00u4ziveVVEGt5RKJrDR8MHE/wJo9Nnad56RLzS2RMA==}
prelude-ls@1.2.1: prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -8841,6 +8867,9 @@ packages:
tiptap-extension-global-drag-handle@0.1.18: tiptap-extension-global-drag-handle@0.1.18:
resolution: {integrity: sha512-jwFuy1K8DP3a4bFy76Hpc63w1Sil0B7uZ3mvhQomVvUFCU787Lg2FowNhn7NFzeyok761qY2VG+PZ/FDthWUdg==} resolution: {integrity: sha512-jwFuy1K8DP3a4bFy76Hpc63w1Sil0B7uZ3mvhQomVvUFCU787Lg2FowNhn7NFzeyok761qY2VG+PZ/FDthWUdg==}
tld-extract@2.1.0:
resolution: {integrity: sha512-Y9QHWIoDQPJJVm3/pOC7kOfOj7vsNSVZl4JGoEHb605FiwZgIfzSMyU0HC0wYw5Cx8435vaG1yGZtIm1yiQGOw==}
tldts-core@6.1.72: tldts-core@6.1.72:
resolution: {integrity: sha512-FW3H9aCaGTJ8l8RVCR3EX8GxsxDbQXuwetwwgXA2chYdsX+NY1ytCBl61narjjehWmCw92tc1AxlcY3668CU8g==} resolution: {integrity: sha512-FW3H9aCaGTJ8l8RVCR3EX8GxsxDbQXuwetwwgXA2chYdsX+NY1ytCBl61narjjehWmCw92tc1AxlcY3668CU8g==}
@@ -9297,6 +9326,9 @@ packages:
wcwidth@1.0.1: wcwidth@1.0.1:
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
web-vitals@4.2.4:
resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==}
web-worker@1.5.0: web-worker@1.5.0:
resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==} resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==}
@@ -15194,6 +15226,8 @@ snapshots:
dependencies: dependencies:
browserslist: 4.24.2 browserslist: 4.24.2
core-js@3.43.0: {}
core-util-is@1.0.3: {} core-util-is@1.0.3: {}
cors@2.8.5: cors@2.8.5:
@@ -16181,6 +16215,8 @@ snapshots:
optionalDependencies: optionalDependencies:
picomatch: 4.0.2 picomatch: 4.0.2
fflate@0.4.8: {}
fflate@0.8.2: {} fflate@0.8.2: {}
figures@3.2.0: figures@3.2.0:
@@ -18482,12 +18518,21 @@ snapshots:
postgres-range@1.1.4: {} postgres-range@1.1.4: {}
posthog-js@1.255.1:
dependencies:
core-js: 3.43.0
fflate: 0.4.8
preact: 10.26.9
web-vitals: 4.2.4
postmark@4.0.5: postmark@4.0.5:
dependencies: dependencies:
axios: 1.9.0 axios: 1.9.0
transitivePeerDependencies: transitivePeerDependencies:
- debug - debug
preact@10.26.9: {}
prelude-ls@1.2.1: {} prelude-ls@1.2.1: {}
prettier@3.4.1: {} prettier@3.4.1: {}
@@ -19499,6 +19544,8 @@ snapshots:
tiptap-extension-global-drag-handle@0.1.18: {} tiptap-extension-global-drag-handle@0.1.18: {}
tld-extract@2.1.0: {}
tldts-core@6.1.72: {} tldts-core@6.1.72: {}
tldts@6.1.72: tldts@6.1.72:
@@ -19911,6 +19958,8 @@ snapshots:
dependencies: dependencies:
defaults: 1.0.4 defaults: 1.0.4
web-vitals@4.2.4: {}
web-worker@1.5.0: {} web-worker@1.5.0: {}
webidl-conversions@3.0.1: {} webidl-conversions@3.0.1: {}