Compare commits

..

1 Commits

Author SHA1 Message Date
6f759aaf85 fix: revert tiptap version 2025-06-13 13:38:20 -07:00
60 changed files with 341 additions and 1316 deletions

View File

@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.21.0",
"version": "0.20.4",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@ -25,7 +25,6 @@
"@tabler/icons-react": "^3.34.0",
"@tanstack/react-query": "^5.80.6",
"@tiptap/extension-character-count": "^2.10.3",
"alfaaz": "^1.1.0",
"axios": "^1.9.0",
"clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
@ -41,7 +40,6 @@
"lowlight": "^3.3.0",
"mermaid": "^11.6.0",
"mitt": "^3.0.1",
"posthog-js": "^1.255.1",
"react": "^18.3.1",
"react-arborist": "3.4.0",
"react-clear-modal": "^2.0.15",

View File

@ -354,9 +354,6 @@
"Character count: {{characterCount}}": "Character count: {{characterCount}}",
"New update": "New update",
"{{latestVersion}} is available": "{{latestVersion}} is available",
"Default page edit mode": "Default page edit mode",
"Choose your preferred page edit mode. Avoid accidental edits.": "Choose your preferred page edit mode. Avoid accidental edits.",
"Reading": "Reading"
"Delete member": "Delete member",
"Member deleted successfully": "Member deleted successfully",
"Are you sure you want to delete this workspace member? This action is irreversible.": "Are you sure you want to delete this workspace member? This action is irreversible.",

View File

@ -1,8 +1,6 @@
import { UserProvider } from "@/features/user/user-provider.tsx";
import { Outlet } from "react-router-dom";
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() {
return (
@ -10,7 +8,6 @@ export default function Layout() {
<GlobalAppShell>
<Outlet />
</GlobalAppShell>
{isCloud() && <PosthogUser />}
</UserProvider>
);
}

View File

@ -30,12 +30,12 @@ export default function BillingDetails() {
>
Plan
</Text>
<Text fw={700} fz="lg" tt="capitalize">
{plans.find(
(plan) => plan.productId === billing.stripeProductId,
)?.name ||
billing.planName ||
"Standard"}
<Text fw={700} fz="lg">
{
plans.find(
(plan) => plan.productId === billing.stripeProductId,
)?.name
}
</Text>
</div>
</Group>
@ -112,58 +112,18 @@ export default function BillingDetails() {
fz="xs"
className={classes.label}
>
Cost
Total
</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>
{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>
</Group>
</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>
</div>
);

View File

@ -2,28 +2,24 @@ import {
Button,
Card,
List,
SegmentedControl,
ThemeIcon,
Title,
Text,
Group,
Select,
Container,
Stack,
Badge,
Flex,
Switch,
} from "@mantine/core";
import { useState } from "react";
import { IconCheck } from "@tabler/icons-react";
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
export default function BillingPlans() {
const { data: plans } = useBillingPlans();
const [isAnnual, setIsAnnual] = useState(true);
const [selectedTierValue, setSelectedTierValue] = useState<string | null>(
null,
);
const [interval, setInterval] = useState("yearly");
if (!plans) {
return null;
}
const handleCheckout = async (priceId: string) => {
try {
@ -36,153 +32,84 @@ 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 (
<Container size="xl" py="xl">
{/* Controls Section */}
<Stack gap="xl" mb="md">
{/* Team Size and Billing Controls */}
<Group justify="center" align="center" gap="sm">
<Select
label="Team size"
description="Select the number of users"
value={selectedTierValue}
onChange={setSelectedTierValue}
data={selectData}
w={250}
size="md"
allowDeselect={false}
/>
<Group justify="center" p="xl">
{plans.map((plan) => {
const price =
interval === "monthly" ? plan.price.monthly : plan.price.yearly;
const priceId = interval === "monthly" ? plan.monthlyId : plan.yearlyId;
const yearlyMonthPrice = parseInt(plan.price.yearly) / 12;
<Group justify="center" align="start">
<Flex justify="center" gap="md" align="center">
<Text size="md">Monthly</Text>
<Switch
defaultChecked={isAnnual}
onChange={(event) => setIsAnnual(event.target.checked)}
size="sm"
/>
<Text size="md">
Annually
<Badge component="span" variant="light" color="blue">
15% OFF
</Badge>
</Text>
</Flex>
</Group>
</Group>
</Stack>
return (
<Card
key={plan.name}
withBorder
radius="md"
shadow="sm"
p="xl"
w={300}
>
<SegmentedControl
value={interval}
onChange={setInterval}
fullWidth
data={[
{ label: "Monthly", value: "monthly" },
{ label: "Yearly (25% OFF)", value: "yearly" },
]}
/>
{/* Plans Grid */}
<Group justify="center" gap="lg" align="stretch">
{plans.map((plan, index) => {
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
<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>
</Stack>
</>
)}
{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>
{/* CTA Button */}
<Button onClick={() => handleCheckout(priceId)} fullWidth>
Upgrade
</Button>
<Card.Section mt="lg">
<Button onClick={() => handleCheckout(priceId)} fullWidth>
Subscribe
</Button>
</Card.Section>
{/* 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>
<Card.Section mt="md">
<List
spacing="xs"
size="sm"
center
icon={
<ThemeIcon variant="light" size={24} radius="xl">
<IconCheck size={16} />
</ThemeIcon>
}
>
{plan.features.map((feature, index) => (
<List.Item key={index}>{feature}</List.Item>
))}
</List>
</Card.Section>
</Card>
);
})}
</Group>
);
}

View File

@ -25,11 +25,6 @@ export interface IBilling {
createdAt: Date;
updatedAt: Date;
deletedAt: Date;
billingScheme: string | null;
tieredUpTo: string | null;
tieredFlatAmount: number | null;
tieredUnitAmount: number | null;
planName: string | null;
}
export interface ICheckoutLink {
@ -47,18 +42,9 @@ export interface IBillingPlan {
monthlyId: string;
yearlyId: string;
currency: string;
price?: {
price: {
monthly: string;
yearly: string;
};
features: string[];
billingScheme: string | null;
pricingTiers: PricingTier[];
}
interface PricingTier {
upTo: number;
monthly?: number;
yearly?: number;
custom?: boolean;
}

View File

@ -1,310 +0,0 @@
import { Button, Group, Text, Modal, TextInput, Alert, Code, Stack, Table } from "@mantine/core";
import * as z from "zod";
import { useState, useMemo } from "react";
import { useDisclosure } from "@mantine/hooks";
import * as React from "react";
import { useForm, zodResolver } from "@mantine/form";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { addCustomDomain, removeCustomDomain, verifyDnsConfiguration } from "@/features/workspace/services/workspace-service.ts";
import { useAtom } from "jotai/index";
import {
currentUserAtom,
workspaceAtom,
} from "@/features/user/atoms/current-user-atom.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { RESET } from "jotai/utils";
import { IconAlertCircle, IconCheck, IconX } from "@tabler/icons-react";
export default function ManageCustomDomain() {
const { t } = useTranslation();
const [customDomainOpened, { open: openCustomDomain, close: closeCustomDomain }] = useDisclosure(false);
const [workspace] = useAtom(workspaceAtom);
const { isAdmin } = useUserRole();
return (
<Stack gap="md">
{workspace?.customDomain && (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Custom Domain")}</Text>
<Text size="sm" c="dimmed" fw={500}>
{workspace.customDomain}
</Text>
</div>
{isAdmin && (
<Button onClick={openCustomDomain} variant="default" color="red">
{t("Remove custom domain")}
</Button>
)}
</Group>
)}
{!workspace?.customDomain && isAdmin && (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Custom Domain")}</Text>
<Text size="sm" c="dimmed">
{t("Add a custom domain to your workspace")}
</Text>
</div>
<Button onClick={openCustomDomain} variant="default">
{t("Add custom domain")}
</Button>
</Group>
)}
<Modal
opened={customDomainOpened}
onClose={closeCustomDomain}
title={workspace?.customDomain ? t("Remove custom domain") : t("Add custom domain")}
centered
size="lg"
>
{workspace?.customDomain ? (
<RemoveCustomDomainForm onClose={closeCustomDomain} />
) : (
<AddCustomDomainForm onClose={closeCustomDomain} />
)}
</Modal>
</Stack>
);
}
interface AddCustomDomainFormProps {
onClose: () => void;
}
const customDomainSchema = z.object({
domain: z.string().min(1, { message: "Domain is required" }).regex(/^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?\.[a-zA-Z]{2,}$/, {
message: "Please enter a valid domain (e.g., example.com)"
}),
});
type CustomDomainFormValues = z.infer<typeof customDomainSchema>;
function AddCustomDomainForm({ onClose }: AddCustomDomainFormProps) {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [isVerifying, setIsVerifying] = useState(false);
const [verificationResult, setVerificationResult] = useState<{
isValid: boolean;
message: string;
isSubdomain: boolean;
} | null>(null);
const [currentUser, setCurrentUser] = useAtom(currentUserAtom);
const form = useForm<CustomDomainFormValues>({
validate: zodResolver(customDomainSchema),
initialValues: {
domain: "",
},
});
// Memoize table content to prevent unnecessary re-renders
const tableContent = useMemo(() => {
const isSubdomain = verificationResult?.isSubdomain;
return (
<Table striped withTableBorder withColumnBorders mt="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Record Type</Table.Th>
<Table.Th>Host</Table.Th>
<Table.Th>Value</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isSubdomain ? (
<Table.Tr>
<Table.Td>CNAME</Table.Td>
<Table.Td>{form.values.domain}</Table.Td>
<Table.Td>app.docmost.com</Table.Td>
</Table.Tr>
) : (
<>
<Table.Tr>
<Table.Td>CNAME</Table.Td>
<Table.Td>www.{form.values.domain}</Table.Td>
<Table.Td>app.docmost.com</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>A</Table.Td>
<Table.Td>{form.values.domain}</Table.Td>
<Table.Td>YOUR_APP_IP</Table.Td>
</Table.Tr>
</>
)}
</Table.Tbody>
</Table>
);
}, [verificationResult?.isSubdomain, form.values.domain]);
async function handleVerifyDns() {
const domain = form.values.domain;
if (!domain) return;
setIsVerifying(true);
// Don't reset verification result immediately to prevent flicker
// Only reset if we're starting a new verification
try {
const result = await verifyDnsConfiguration({ domain });
setVerificationResult({
isValid: result.isValid,
message: result.message,
isSubdomain: result.isSubdomain,
});
} catch (err) {
setVerificationResult({
isValid: false,
message: err?.response?.data?.message || "Failed to verify DNS configuration",
isSubdomain: false,
});
}
setIsVerifying(false);
}
async function handleSubmit(data: CustomDomainFormValues) {
setIsLoading(true);
try {
await addCustomDomain({ domain: data.domain });
setCurrentUser(RESET);
notifications.show({
message: "Custom domain added successfully! Redirecting...",
color: "green",
icon: <IconCheck size={16} />,
});
// Redirect to the new custom domain
setTimeout(() => {
window.location.href = `https://${data.domain}`;
}, 2000);
} catch (err) {
notifications.show({
message: err?.response?.data?.message || "Failed to add custom domain",
color: "red",
icon: <IconX size={16} />,
});
}
setIsLoading(false);
}
const isSubdomain = verificationResult?.isSubdomain;
const isValid = verificationResult?.isValid;
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<TextInput
type="text"
placeholder="example.com"
label="Custom Domain"
variant="filled"
description="Enter your domain (e.g., example.com or subdomain.example.com)"
{...form.getInputProps("domain")}
/>
<Alert icon={<IconAlertCircle size={16} />} title="DNS Configuration Required" color="blue">
<Text size="sm" mb="xs">
Before adding your custom domain, you need to configure your DNS settings:
</Text>
{tableContent}
</Alert>
<Button
type="button"
variant="outline"
onClick={handleVerifyDns}
loading={isVerifying}
disabled={!form.values.domain || !!form.errors.domain}
>
{t("Verify DNS Configuration")}
</Button>
{verificationResult && (
<Alert
icon={isValid ? <IconCheck size={16} /> : <IconX size={16} />}
title={isValid ? "DNS Configuration Valid" : "DNS Configuration Invalid"}
color={isValid ? "green" : "red"}
>
<Text size="sm">{verificationResult.message}</Text>
</Alert>
)}
<Group justify="flex-end" mt="md">
<Button
type="submit"
disabled={isLoading || !isValid}
loading={isLoading}
>
{t("Add Custom Domain")}
</Button>
</Group>
</Stack>
</form>
);
}
interface RemoveCustomDomainFormProps {
onClose: () => void;
}
function RemoveCustomDomainForm({ onClose }: RemoveCustomDomainFormProps) {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [currentUser, setCurrentUser] = useAtom(currentUserAtom);
async function handleRemove() {
if (!currentUser?.workspace?.customDomain) return;
setIsLoading(true);
try {
await removeCustomDomain({ domain: currentUser.workspace.customDomain });
setCurrentUser(RESET);
notifications.show({
message: "Custom domain removed successfully!",
color: "green",
icon: <IconCheck size={16} />,
});
onClose();
} catch (err) {
notifications.show({
message: err?.response?.data?.message || "Failed to remove custom domain",
color: "red",
icon: <IconX size={16} />,
});
}
setIsLoading(false);
}
return (
<Stack gap="md">
<Alert icon={<IconAlertCircle size={16} />} title="Remove Custom Domain" color="red">
<Text size="sm">
Are you sure you want to remove the custom domain <Code>{currentUser?.workspace?.customDomain}</Code>?
This action cannot be undone.
</Text>
</Alert>
<Group justify="flex-end">
<Button variant="outline" onClick={onClose}>
{t("Cancel")}
</Button>
<Button
color="red"
onClick={handleRemove}
disabled={isLoading}
loading={isLoading}
>
{t("Remove Custom Domain")}
</Button>
</Group>
</Stack>
);
}

View File

@ -1,41 +0,0 @@
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;
}

View File

@ -18,7 +18,6 @@ import classes from "@/features/auth/components/auth.module.css";
import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
import { useTranslation } from "react-i18next";
import SsoLogin from "@/ee/components/sso-login.tsx";
const formSchema = z.object({
name: z.string().trim().min(1),
@ -72,43 +71,39 @@ export function InviteSignUpForm() {
{t("Join the workspace")}
</Title>
<SsoLogin />
<Stack align="stretch" justify="center" gap="xl">
<form onSubmit={form.onSubmit(onSubmit)}>
<TextInput
id="name"
type="text"
label={t("Name")}
placeholder={t("enter your full name")}
variant="filled"
{...form.getInputProps("name")}
/>
{!invitation.enforceSso && (
<Stack align="stretch" justify="center" gap="xl">
<form onSubmit={form.onSubmit(onSubmit)}>
<TextInput
id="name"
type="text"
label={t("Name")}
placeholder={t("enter your full name")}
variant="filled"
{...form.getInputProps("name")}
/>
<TextInput
id="email"
type="email"
label={t("Email")}
value={invitation.email}
disabled
variant="filled"
mt="md"
/>
<TextInput
id="email"
type="email"
label={t("Email")}
value={invitation.email}
disabled
variant="filled"
mt="md"
/>
<PasswordInput
label={t("Password")}
placeholder={t("Your password")}
variant="filled"
mt="md"
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
{t("Sign Up")}
</Button>
</form>
</Stack>
)}
<PasswordInput
label={t("Password")}
placeholder={t("Your password")}
variant="filled"
mt="md"
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
{t("Sign Up")}
</Button>
</form>
</Stack>
</Box>
</Container>
);

View File

@ -21,7 +21,7 @@ import { Link } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route.ts";
const formSchema = z.object({
workspaceName: z.string().trim().max(50).optional(),
workspaceName: z.string().trim().min(3).max(50),
name: z.string().min(1).max(50),
email: z
.string()
@ -60,17 +60,15 @@ export function SetupWorkspaceForm() {
{isCloud() && <SsoCloudSignup />}
<form onSubmit={form.onSubmit(onSubmit)}>
{!isCloud() && (
<TextInput
id="workspaceName"
type="text"
label={t("Workspace Name")}
placeholder={t("e.g ACME Inc")}
variant="filled"
mt="md"
{...form.getInputProps("workspaceName")}
/>
)}
<TextInput
id="workspaceName"
type="text"
label={t("Workspace Name")}
placeholder={t("e.g ACME Inc")}
variant="filled"
mt="md"
{...form.getInputProps("workspaceName")}
/>
<TextInput
id="name"

View File

@ -10,7 +10,7 @@ export interface IRegister {
}
export interface ISetupWorkspace {
workspaceName?: string;
workspaceName: string;
name: string;
email: string;
password: string;

View File

@ -156,11 +156,13 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
)
}
onClick={() => {
if (name === "Default") {
editor.commands.unsetColor();
} else {
editor.chain().focus().setColor(color || "").run();
}
editor.commands.unsetColor();
name !== "Default" &&
editor
.chain()
.focus()
.setColor(color || "")
.run();
setIsOpen(false);
}}
style={{ border: "none" }}

View File

@ -32,7 +32,7 @@ const schema = z.object({
export default function EmbedView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, selected, updateAttributes, editor } = props;
const { node, selected, updateAttributes } = props;
const { src, provider } = node.attrs;
const embedUrl = useMemo(() => {
@ -50,10 +50,6 @@ export default function EmbedView(props: NodeViewProps) {
});
async function onSubmit(data: { url: string }) {
if (!editor.isEditable) {
return;
}
if (provider) {
const embedProvider = getEmbedProviderById(provider);
if (embedProvider.id === "iframe") {
@ -89,13 +85,7 @@ export default function EmbedView(props: NodeViewProps) {
</AspectRatio>
</>
) : (
<Popover
width={300}
position="bottom"
withArrow
shadow="md"
disabled={!editor.isEditable}
>
<Popover width={300} position="bottom" withArrow shadow="md">
<Popover.Target>
<Card
radius="md"

View File

@ -73,7 +73,6 @@ import i18n from "@/i18n.ts";
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
import EmojiCommand from "./emoji-command";
import { CharacterCount } from "@tiptap/extension-character-count";
import { countWords } from "alfaaz";
const lowlight = createLowlight(common);
lowlight.register("mermaid", plaintext);
@ -214,9 +213,7 @@ export const mainExtensions = [
MarkdownClipboard.configure({
transformPastedText: true,
}),
CharacterCount.configure({
wordCounter: (text) => countWords(text),
}),
CharacterCount
] as any;
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
@ -232,4 +229,4 @@ export const collabExtensions: CollabExtensions = (provider, user) => [
color: randomElement(userColors),
},
}),
];
];

View File

@ -42,11 +42,7 @@ export function FullEditor({
spaceSlug={spaceSlug}
editable={editable}
/>
<MemoizedPageEditor
pageId={pageId}
editable={editable}
content={content}
/>
<MemoizedPageEditor pageId={pageId} editable={editable} content={content} />
</Container>
);
}

View File

@ -1,6 +1,7 @@
import "@/features/editor/styles/index.css";
import React, {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
@ -51,7 +52,6 @@ import { IPage } from "@/features/page/types/page.types.ts";
import { useParams } from "react-router-dom";
import { extractPageSlugId } from "@/lib";
import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode";
interface PageEditorProps {
@ -71,11 +71,7 @@ export default function PageEditor({
const [, setAsideState] = useAtom(asideStateAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const ydocRef = useRef<Y.Doc | null>(null);
if (!ydocRef.current) {
ydocRef.current = new Y.Doc();
}
const ydoc = ydocRef.current;
const ydoc = useMemo(() => new Y.Doc(), [pageId]);
const [isLocalSynced, setLocalSynced] = useState(false);
const [isRemoteSynced, setRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
@ -89,103 +85,67 @@ export default function PageEditor({
const [isCollabReady, setIsCollabReady] = useState(false);
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
// Providers only created once per pageId
const providersRef = useRef<{
local: IndexeddbPersistence;
remote: HocuspocusProvider;
} | null>(null);
const [providersReady, setProvidersReady] = useState(false);
const localProvider = useMemo(() => {
const provider = new IndexeddbPersistence(documentName, ydoc);
const localProvider = providersRef.current?.local;
const remoteProvider = providersRef.current?.remote;
provider.on("synced", () => {
setLocalSynced(true);
});
// Track when collaborative provider is ready and synced
const [collabReady, setCollabReady] = useState(false);
useEffect(() => {
if (
remoteProvider?.status === WebSocketStatus.Connected &&
isLocalSynced &&
isRemoteSynced
) {
setCollabReady(true);
}
}, [remoteProvider?.status, isLocalSynced, isRemoteSynced]);
return provider;
}, [pageId, ydoc]);
useEffect(() => {
if (!providersRef.current) {
const local = new IndexeddbPersistence(documentName, ydoc);
local.on("synced", () => setLocalSynced(true));
const remote = new HocuspocusProvider({
name: documentName,
url: collaborationURL,
document: ydoc,
token: collabQuery?.token,
connect: true,
preserveConnection: false,
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => {
const payload = jwtDecode(collabQuery?.token);
const now = Date.now().valueOf() / 1000;
const isTokenExpired = now >= payload.exp;
if (isTokenExpired) {
refetchCollabToken();
}
},
onStatus: (status) => {
if (status.status === "connected") {
setYjsConnectionStatus(status.status);
}
},
});
remote.on("synced", () => setRemoteSynced(true));
remote.on("disconnect", () => {
setYjsConnectionStatus(WebSocketStatus.Disconnected);
});
providersRef.current = { local, remote };
setProvidersReady(true);
} else {
setProvidersReady(true);
}
// Only destroy on final unmount
const remoteProvider = useMemo(() => {
const provider = new HocuspocusProvider({
name: documentName,
url: collaborationURL,
document: ydoc,
token: collabQuery?.token,
connect: false,
preserveConnection: false,
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => {
const payload = jwtDecode(collabQuery?.token);
const now = Date.now().valueOf() / 1000;
const isTokenExpired = now >= payload.exp;
if (isTokenExpired) {
refetchCollabToken();
}
},
onStatus: (status) => {
if (status.status === "connected") {
setYjsConnectionStatus(status.status);
}
},
});
provider.on("synced", () => {
setRemoteSynced(true);
});
provider.on("disconnect", () => {
setYjsConnectionStatus(WebSocketStatus.Disconnected);
});
return provider;
}, [ydoc, pageId, collabQuery?.token]);
useLayoutEffect(() => {
remoteProvider.connect();
return () => {
providersRef.current?.remote.destroy();
providersRef.current?.local.destroy();
providersRef.current = null;
setRemoteSynced(false);
setLocalSynced(false);
remoteProvider.destroy();
localProvider.destroy();
};
}, [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]);
}, [remoteProvider, localProvider]);
const extensions = useMemo(() => {
if (!remoteProvider || !currentUser?.user) return mainExtensions;
return [
...mainExtensions,
...collabExtensions(remoteProvider, currentUser?.user),
];
}, [remoteProvider, currentUser?.user]);
}, [ydoc, pageId, remoteProvider, currentUser?.user]);
const editor = useEditor(
{
@ -239,7 +199,7 @@ export default function PageEditor({
debouncedUpdateContent(editorJson);
},
},
[pageId, editable, remoteProvider],
[pageId, editable, remoteProvider?.status],
);
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
@ -292,6 +252,29 @@ export default function PageEditor({
}
}, [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;
useEffect(() => {
@ -307,49 +290,11 @@ export default function PageEditor({
return () => clearTimeout(collabReadyTimeout);
}, [isRemoteSynced, isLocalSynced, remoteProvider?.status]);
useEffect(() => {
// Only honor user default page edit mode preference and permissions
if (editor) {
if (userPageEditMode && editable) {
if (userPageEditMode === PageEditMode.Edit) {
editor.setEditable(true);
} else if (userPageEditMode === PageEditMode.Read) {
editor.setEditable(false);
}
} else {
editor.setEditable(false);
}
}
}, [userPageEditMode, editor, editable]);
const hasConnectedOnceRef = useRef(false);
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" }}>
return isCollabReady ? (
<div>
<div ref={menuContainerRef}>
<EditorContent editor={editor} />
{editor && editor.isEditable && (
<div>
<EditorBubbleMenu editor={editor} />
@ -363,12 +308,21 @@ export default function PageEditor({
<LinkMenu editor={editor} appendTo={menuContainerRef} />
</div>
)}
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
</div>
<div
onClick={() => editor.commands.focus("end")}
style={{ paddingBottom: "20vh" }}
></div>
</div>
) : (
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={mainExtensions}
content={content}
></EditorProvider>
);
}

View File

@ -21,8 +21,6 @@ import { useTranslation } from "react-i18next";
import EmojiCommand from "@/features/editor/extensions/emoji-command.ts";
import { UpdateEvent } from "@/features/websocket/types";
import localEmitter from "@/lib/local-emitter.ts";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
export interface TitleEditorProps {
pageId: string;
@ -46,9 +44,6 @@ export function TitleEditor({
const emit = useQueryEmit();
const navigate = useNavigate();
const [activePageId, setActivePageId] = useState(pageId);
const [currentUser] = useAtom(currentUserAtom);
const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const titleEditor = useEditor({
extensions: [
@ -141,24 +136,9 @@ export function TitleEditor({
};
}, [pageId]);
useEffect(() => {
// honor user default page edit mode preference
if (userPageEditMode && titleEditor && editable) {
if (userPageEditMode === PageEditMode.Edit) {
titleEditor.setEditable(true);
} else if (userPageEditMode === PageEditMode.Read) {
titleEditor.setEditable(false);
}
}
}, [userPageEditMode, titleEditor, editable]);
function handleTitleKeyDown(event: any) {
function handleTitleKeyDown(event) {
if (!titleEditor || !pageEditor || event.shiftKey) return;
// Prevent focus shift when IME composition is active
// `keyCode === 229` is added to support Safari where `isComposing` may not be reliable
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return;
const { key } = event;
const { $head } = titleEditor.state.selection;

View File

@ -1,30 +1,24 @@
.breadcrumbs {
display: flex;
align-items: center;
overflow: hidden;
flex-wrap: nowrap;
a {
color: var(--mantine-color-default-color);
line-height: inherit;
}
.mantine-Breadcrumbs-breadcrumb {
min-width: 1px;
display: flex;
align-items: center;
overflow: hidden;
}
flex-wrap: nowrap;
a {
color: var(--mantine-color-default-color);
line-height: inherit;
}
.mantine-Breadcrumbs-breadcrumb {
min-width: 1px;
overflow: hidden;
}
}
.truncatedText {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.breadcrumbDiv {
overflow: hidden;
@media (max-width: $mantine-breakpoint-sm) {
overflow: visible;
}
}

View File

@ -161,7 +161,7 @@ export default function Breadcrumb() {
};
return (
<div className={classes.breadcrumbDiv}>
<div style={{ overflow: "hidden" }}>
{breadcrumbNodes && (
<Breadcrumbs className={classes.breadcrumbs}>
{isMobile ? getMobileBreadcrumbItems() : getBreadcrumbItems()}

View File

@ -33,7 +33,6 @@ import {
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import { formattedDate, timeAgo } from "@/lib/time.ts";
import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import ShareModal from "@/features/share/components/share-modal.tsx";
@ -60,8 +59,6 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
</Tooltip>
)}
{!readOnly && <PageStateSegmentedControl size="xs" />}
<ShareModal readOnly={readOnly} />
<Tooltip label={t("Comments")} openDelay={250} withArrow>

View File

@ -1,27 +1,15 @@
.header {
height: 45px;
background-color: var(--mantine-color-body);
padding-left: var(--mantine-spacing-md);
padding-right: var(--mantine-spacing-md);
position: fixed;
z-index: 99;
top: var(--app-shell-header-offset, 0rem);
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
inset-inline-end: var(--app-shell-aside-offset, 0rem);
height: 45px;
background-color: var(--mantine-color-body);
padding-left: var(--mantine-spacing-md);
padding-right: var(--mantine-spacing-md);
position: fixed;
z-index: 99;
top: var(--app-shell-header-offset, 0rem);
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
inset-inline-end: var(--app-shell-aside-offset, 0rem);
@media (max-width: $mantine-breakpoint-sm) {
padding-left: var(--mantine-spacing-xs);
padding-right: var(--mantine-spacing-xs);
}
@media print {
display: none;
}
}
.group {
@media (max-width: $mantine-breakpoint-sm) {
gap: var(--mantine-spacing-sm);
padding-inline: 0 !important;
}
@media print {
display: none;
}
}

View File

@ -9,10 +9,10 @@ interface Props {
export default function PageHeader({ readOnly }: Props) {
return (
<div className={classes.header}>
<Group justify="space-between" h="100%" px="md" wrap="nowrap" className={classes.group}>
<Group justify="space-between" h="100%" px="md" wrap="nowrap">
<Breadcrumb />
<Group justify="flex-end" h="100%" px="md" wrap="nowrap" gap="var(--mantine-spacing-xs)">
<Group justify="flex-end" h="100%" px="md" wrap="nowrap">
<PageHeaderMenu readOnly={readOnly} />
</Group>
</Group>

View File

@ -65,7 +65,6 @@ export interface IPageInput {
icon: string;
coverPhoto: string;
position: string;
isLocked: boolean;
}
export interface IExportPageParams {

View File

@ -11,7 +11,7 @@ import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
const formSchema = z.object({
name: z.string().min(1).max(40),
name: z.string().min(2).max(40),
});
type FormValues = z.infer<typeof formSchema>;

View File

@ -1,65 +0,0 @@
import { Group, Text, MantineSize, SegmentedControl } from "@mantine/core";
import { useAtom } from "jotai";
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
import { updateUser } from "@/features/user/services/user-service.ts";
import React, { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { PageEditMode } from "@/features/user/types/user.types.ts";
export default function PageStatePref() {
const { t } = useTranslation();
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Default page edit mode")}</Text>
<Text size="sm" c="dimmed">
{t("Choose your preferred page edit mode. Avoid accidental edits.")}
</Text>
</div>
<PageStateSegmentedControl />
</Group>
);
}
interface PageStateSegmentedControlProps {
size?: MantineSize;
}
export function PageStateSegmentedControl({
size,
}: PageStateSegmentedControlProps) {
const { t } = useTranslation();
const [user, setUser] = useAtom(userAtom);
const pageEditMode =
user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const [value, setValue] = useState(pageEditMode);
const handleChange = useCallback(
async (value: string) => {
const updatedUser = await updateUser({ pageEditMode: value });
setValue(value);
setUser(updatedUser);
},
[user, setUser],
);
useEffect(() => {
if (pageEditMode !== value) {
setValue(pageEditMode);
}
}, [pageEditMode, value]);
return (
<SegmentedControl
size={size}
value={value}
onChange={handleChange}
data={[
{ label: t("Edit"), value: PageEditMode.Edit },
{ label: t("Read"), value: PageEditMode.Read },
]}
/>
);
}

View File

@ -19,7 +19,6 @@ export interface IUser {
deactivatedAt: Date;
deletedAt: Date;
fullPageWidth: boolean; // used for update
pageEditMode: string; // used for update
}
export interface ICurrentUser {
@ -30,11 +29,5 @@ export interface ICurrentUser {
export interface IUserSettings {
preferences: {
fullPageWidth: boolean;
pageEditMode: string;
};
}
export enum PageEditMode {
Read = "read",
Edit = "edit",
}
}

View File

@ -11,7 +11,7 @@ import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
const formSchema = z.object({
name: z.string().min(1),
name: z.string().min(4),
});
type FormValues = z.infer<typeof formSchema>;

View File

@ -173,7 +173,7 @@ export function useRevokeInvitationMutation() {
export function useGetInvitationQuery(
invitationId: string,
): UseQueryResult<IInvitation, Error> {
): UseQueryResult<any, Error> {
return useQuery({
queryKey: ["invitations", invitationId],
queryFn: () => getInvitationById({ invitationId }),

View File

@ -120,19 +120,3 @@ export async function uploadLogo(file: File) {
});
return req.data;
}
// Custom Domain Functions
export async function addCustomDomain(data: { domain: string }) {
const req = await api.post("/custom-domain/add", data);
return req.data;
}
export async function removeCustomDomain(data: { domain: string }) {
const req = await api.delete("/custom-domain/remove", { data });
return req.data;
}
export async function verifyDnsConfiguration(data: { domain: string }) {
const req = await api.post("/custom-domain/verify-dns", data);
return req.data;
}

View File

@ -12,7 +12,6 @@ export interface IWorkspace {
settings: any;
status: string;
enforceSso: boolean;
stripeCustomerId: string;
billingEmail: string;
trialEndAt: Date;
createdAt: Date;
@ -36,7 +35,6 @@ export interface IInvitation {
workspaceId: string;
invitedById: string;
createdAt: Date;
enforceSso: boolean;
}
export interface IInvitationLink {

View File

@ -83,18 +83,6 @@ export function getBillingTrialDays() {
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 {
const rawValue = import.meta.env.DEV
? process?.env?.[key]

View File

@ -3,7 +3,7 @@ import "@mantine/spotlight/styles.css";
import "@mantine/notifications/styles.css";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import { mantineCssResolver, theme } from "@/theme";
import { mantineCssResolver, theme } from '@/theme';
import { MantineProvider } from "@mantine/core";
import { BrowserRouter } from "react-router-dom";
import { ModalsProvider } from "@mantine/modals";
@ -11,14 +11,6 @@ import { Notifications } from "@mantine/notifications";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { HelmetProvider } from "react-helmet-async";
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({
defaultOptions: {
@ -31,16 +23,9 @@ 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(
document.getElementById("root") as HTMLElement,
document.getElementById("root") as HTMLElement
);
root.render(
@ -50,12 +35,10 @@ root.render(
<QueryClientProvider client={queryClient}>
<Notifications position="bottom-center" limit={3} />
<HelmetProvider>
<PostHogProvider client={posthog}>
<App />
</PostHogProvider>
<App />
</HelmetProvider>
</QueryClientProvider>
</ModalsProvider>
</MantineProvider>
</BrowserRouter>,
</BrowserRouter>
);

View File

@ -12,11 +12,6 @@ import {
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
import { useTranslation } from "react-i18next";
import React from "react";
const MemoizedFullEditor = React.memo(FullEditor);
const MemoizedPageHeader = React.memo(PageHeader);
const MemoizedHistoryModal = React.memo(HistoryModal);
export default function Page() {
const { t } = useTranslation();
@ -54,14 +49,14 @@ export default function Page() {
<title>{`${page?.icon || ""} ${page?.title || t("untitled")}`}</title>
</Helmet>
<MemoizedPageHeader
<PageHeader
readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
)}
/>
<MemoizedFullEditor
<FullEditor
key={page.id}
pageId={page.id}
title={page.title}
@ -73,7 +68,7 @@ export default function Page() {
SpaceCaslSubject.Page,
)}
/>
<MemoizedHistoryModal pageId={page.id} />
<HistoryModal pageId={page.id} />
</div>
)
);

View File

@ -2,7 +2,6 @@ import SettingsTitle from "@/components/settings/settings-title.tsx";
import AccountLanguage from "@/features/user/components/account-language.tsx";
import AccountTheme from "@/features/user/components/account-theme.tsx";
import PageWidthPref from "@/features/user/components/page-width-pref.tsx";
import PageEditPref from "@/features/user/components/page-state-pref";
import { getAppName } from "@/lib/config.ts";
import { Divider } from "@mantine/core";
import { Helmet } from "react-helmet-async";
@ -29,10 +28,6 @@ export default function AccountPreferences() {
<Divider my={"md"} />
<PageWidthPref />
<Divider my={"md"} />
<PageEditPref />
</>
);
}

View File

@ -5,7 +5,6 @@ import { getAppName, isCloud } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
import ManageHostname from "@/ee/components/manage-hostname.tsx";
import { Divider } from "@mantine/core";
import ManageCustomDomain from "@/ee/components/manage-custom-domain";
export default function WorkspaceSettings() {
const { t } = useTranslation();
@ -21,8 +20,6 @@ export default function WorkspaceSettings() {
<>
<Divider my="md" />
<ManageHostname />
<Divider my="md" />
<ManageCustomDomain />
</>
)}
</>

View File

@ -14,8 +14,6 @@ export default defineConfig(({ mode }) => {
SUBDOMAIN_HOST,
COLLAB_URL,
BILLING_TRIAL_DAYS,
POSTHOG_HOST,
POSTHOG_KEY,
} = loadEnv(mode, envPath, "");
return {
@ -29,8 +27,6 @@ export default defineConfig(({ mode }) => {
SUBDOMAIN_HOST,
COLLAB_URL,
BILLING_TRIAL_DAYS,
POSTHOG_HOST,
POSTHOG_KEY,
},
APP_VERSION: JSON.stringify(process.env.npm_package_version),
},

View File

@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.21.0",
"version": "0.20.4",
"description": "",
"author": "",
"private": true,
@ -82,7 +82,6 @@
"sanitize-filename-ts": "^1.0.2",
"socket.io": "^4.8.1",
"stripe": "^17.5.0",
"tld-extract": "^2.1.0",
"tmp-promise": "^3.0.3",
"ws": "^8.18.2",
"yauzl": "^3.2.0"

View File

@ -16,12 +16,6 @@ export async function comparePasswordHash(
return bcrypt.compare(plainPassword, passwordHash);
}
export function generateRandomSuffixNumbers(length: number) {
return Math.random()
.toFixed(length)
.substring(2, 2 + length);
}
export type RedisConfig = {
host: string;
port: number;

View File

@ -1,4 +1,4 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Injectable, NestMiddleware, NotFoundException } from '@nestjs/common';
import { FastifyRequest, FastifyReply } from 'fastify';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
@ -27,19 +27,8 @@ export class DomainMiddleware implements NestMiddleware {
(req as any).workspace = workspace;
} else if (this.environmentService.isCloud()) {
const header = req.headers.host;
// 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);
if (!workspace) {

View File

@ -1,9 +1,11 @@
import {
BadRequestException,
Body,
Controller,
HttpCode,
HttpStatus,
Post,
Req,
Res,
UseGuards,
} from '@nestjs/common';
@ -21,6 +23,7 @@ import { ForgotPasswordDto } from './dto/forgot-password.dto';
import { PasswordResetDto } from './dto/password-reset.dto';
import { VerifyUserTokenDto } from './dto/verify-user-token.dto';
import { FastifyReply } from 'fastify';
import { addDays } from 'date-fns';
import { validateSsoEnforcement } from './auth.util';
@Controller('auth')
@ -122,7 +125,7 @@ export class AuthController {
res.setCookie('authToken', token, {
httpOnly: true,
path: '/',
expires: this.environmentService.getCookieExpiresIn(),
expires: addDays(new Date(), 30),
secure: this.environmentService.isHttps(),
});
}

View File

@ -6,16 +6,3 @@ export function validateSsoEnforcement(workspace: Workspace) {
throw new BadRequestException('This workspace has enforced SSO login.');
}
}
export function validateAllowedEmail(userEmail: string, workspace: Workspace) {
const emailParts = userEmail.split('@');
const emailDomain = emailParts[1].toLowerCase();
if (
workspace.emailDomains?.length > 0 &&
!workspace.emailDomains.includes(emailDomain)
) {
throw new BadRequestException(
`The email domain "${emailDomain}" is not approved for this workspace.`,
);
}
}

View File

@ -1,12 +1,6 @@
import {
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
import { IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator';
import { CreateUserDto } from './create-user.dto';
import { Transform, TransformFnParams } from 'class-transformer';
import {Transform, TransformFnParams} from "class-transformer";
export class CreateAdminUserDto extends CreateUserDto {
@IsNotEmpty()
@ -15,17 +9,10 @@ export class CreateAdminUserDto extends CreateUserDto {
@Transform(({ value }: TransformFnParams) => value?.trim())
name: string;
@IsOptional()
@MinLength(1)
@IsNotEmpty()
@MinLength(3)
@MaxLength(50)
@IsString()
@Transform(({ value }: TransformFnParams) => value?.trim())
workspaceName: string;
@IsOptional()
@MinLength(4)
@MaxLength(50)
@IsString()
@Transform(({ value }: TransformFnParams) => value?.trim())
hostname?: string;
}

View File

@ -134,7 +134,7 @@ export class AuthService {
const token = nanoIdGen(16);
const resetLink = `${this.domainService.getUrl(workspace.hostname, workspace.customDomain)}/password-reset?token=${token}`;
const resetLink = `${this.domainService.getUrl(workspace.hostname)}/password-reset?token=${token}`;
await this.userTokenRepo.insertUserToken({
token: token,

View File

@ -92,8 +92,7 @@ export class SignupService {
// create workspace with full setup
const workspaceData: CreateWorkspaceDto = {
name: createAdminUserDto.workspaceName || 'My workspace',
hostname: createAdminUserDto.hostname,
name: createAdminUserDto.workspaceName,
};
workspace = await this.workspaceService.create(

View File

@ -1,13 +1,5 @@
import { OmitType, PartialType } from '@nestjs/mapped-types';
import {
IsBoolean,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
import { IsBoolean, IsOptional, IsString } from 'class-validator';
import { CreateUserDto } from '../../auth/dto/create-user.dto';
export class UpdateUserDto extends PartialType(
@ -21,18 +13,7 @@ export class UpdateUserDto extends PartialType(
@IsBoolean()
fullPageWidth: boolean;
@IsOptional()
@IsString()
@IsIn(['read', 'edit'])
pageEditMode: string;
@IsOptional()
@IsString()
locale: string;
@IsOptional()
@MinLength(8)
@MaxLength(70)
@IsString()
confirmPassword: string;
}

View File

@ -50,6 +50,6 @@ export class UserController {
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
return this.userService.update(updateUserDto, user.id, workspace);
return this.userService.update(updateUserDto, user.id, workspace.id);
}
}

View File

@ -3,12 +3,8 @@ import {
BadRequestException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
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()
export class UserService {
@ -21,14 +17,9 @@ export class UserService {
async update(
updateUserDto: UpdateUserDto,
userId: string,
workspace: Workspace,
workspaceId: string,
) {
const includePassword =
updateUserDto.email != null && updateUserDto.confirmPassword != null;
const user = await this.userRepo.findById(userId, workspace.id, {
includePassword,
});
const user = await this.userRepo.findById(userId, workspaceId);
if (!user) {
throw new NotFoundException('User not found');
@ -43,40 +34,14 @@ export class UserService {
);
}
if (typeof updateUserDto.pageEditMode !== 'undefined') {
return this.userRepo.updatePreference(
userId,
'pageEditMode',
updateUserDto.pageEditMode.toLowerCase(),
);
}
if (updateUserDto.name) {
user.name = updateUserDto.name;
}
if (updateUserDto.email && user.email != updateUserDto.email) {
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)) {
if (await this.userRepo.findByEmail(updateUserDto.email, workspaceId)) {
throw new BadRequestException('A user with this email already exists');
}
user.email = updateUserDto.email;
}
@ -88,9 +53,7 @@ export class UserService {
user.locale = updateUserDto.locale;
}
delete updateUserDto.confirmPassword;
await this.userRepo.updateUser(updateUserDto, userId, workspace.id);
await this.userRepo.updateUser(updateUserDto, userId, workspaceId);
return user;
}
}

View File

@ -29,7 +29,9 @@ import WorkspaceAbilityFactory from '../../casl/abilities/workspace-ability.fact
import {
WorkspaceCaslAction,
WorkspaceCaslSubject,
} from '../../casl/interfaces/workspace-ability.type';import { FastifyReply } from 'fastify';
} from '../../casl/interfaces/workspace-ability.type';
import { addDays } from 'date-fns';
import { FastifyReply } from 'fastify';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
import { CheckHostnameDto } from '../dto/check-hostname.dto';
import { RemoveWorkspaceUserDto } from '../dto/remove-workspace-user.dto';
@ -178,13 +180,10 @@ export class WorkspaceController {
@Public()
@HttpCode(HttpStatus.OK)
@Post('invites/info')
async getInvitationById(
@Body() dto: InvitationIdDto,
@AuthWorkspace() workspace: Workspace,
) {
async getInvitationById(@Body() dto: InvitationIdDto, @Req() req: any) {
return this.workspaceInvitationService.getInvitationById(
dto.invitationId,
workspace,
req.raw.workspaceId,
);
}
@ -254,18 +253,18 @@ export class WorkspaceController {
@Post('invites/accept')
async acceptInvite(
@Body() acceptInviteDto: AcceptInviteDto,
@AuthWorkspace() workspace: Workspace,
@Req() req: any,
@Res({ passthrough: true }) res: FastifyReply,
) {
const authToken = await this.workspaceInvitationService.acceptInvitation(
acceptInviteDto,
workspace,
req.raw.workspaceId,
);
res.setCookie('authToken', authToken, {
httpOnly: true,
path: '/',
expires: this.environmentService.getCookieExpiresIn(),
expires: addDays(new Date(), 30),
secure: this.environmentService.isHttps(),
});
}

View File

@ -28,10 +28,6 @@ import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import { Queue } from 'bullmq';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
import {
validateAllowedEmail,
validateSsoEnforcement,
} from '../../auth/auth.util';
@Injectable()
export class WorkspaceInvitationService {
@ -67,19 +63,19 @@ export class WorkspaceInvitationService {
return result;
}
async getInvitationById(invitationId: string, workspace: Workspace) {
async getInvitationById(invitationId: string, workspaceId: string) {
const invitation = await this.db
.selectFrom('workspaceInvitations')
.select(['id', 'email', 'createdAt'])
.where('id', '=', invitationId)
.where('workspaceId', '=', workspace.id)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
if (!invitation) {
throw new NotFoundException('Invitation not found');
}
return { ...invitation, enforceSso: workspace.enforceSso };
return invitation;
}
async getInvitationTokenById(invitationId: string, workspaceId: string) {
@ -145,10 +141,6 @@ export class WorkspaceInvitationService {
groupIds: validGroups?.map((group: Partial<Group>) => group.id),
}));
if (invitesToInsert.length < 1) {
return;
}
invites = await trx
.insertInto('workspaceInvitations')
.values(invitesToInsert)
@ -171,18 +163,18 @@ export class WorkspaceInvitationService {
invitation.email,
invitation.token,
authUser.name,
workspace,
workspace.hostname,
);
});
}
}
async acceptInvitation(dto: AcceptInviteDto, workspace: Workspace) {
async acceptInvitation(dto: AcceptInviteDto, workspaceId: string) {
const invitation = await this.db
.selectFrom('workspaceInvitations')
.selectAll()
.where('id', '=', dto.invitationId)
.where('workspaceId', '=', workspace.id)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
if (!invitation) {
@ -193,9 +185,6 @@ export class WorkspaceInvitationService {
throw new BadRequestException('Invalid invitation token');
}
validateSsoEnforcement(workspace);
validateAllowedEmail(invitation.email, workspace);
let newUser: User;
try {
@ -208,7 +197,7 @@ export class WorkspaceInvitationService {
password: dto.password,
role: invitation.role,
invitedById: invitation.invitedById,
workspaceId: workspace.id,
workspaceId: workspaceId,
},
trx,
);
@ -216,7 +205,7 @@ export class WorkspaceInvitationService {
// add user to default group
await this.groupUserRepo.addUserToDefaultGroup(
newUser.id,
workspace.id,
workspaceId,
trx,
);
@ -226,7 +215,7 @@ export class WorkspaceInvitationService {
.selectFrom('groups')
.select(['id', 'name'])
.where('groups.id', 'in', invitation.groupIds)
.where('groups.workspaceId', '=', workspace.id)
.where('groups.workspaceId', '=', workspaceId)
.execute();
if (validGroups && validGroups.length > 0) {
@ -267,7 +256,7 @@ export class WorkspaceInvitationService {
// notify the inviter
const invitedByUser = await this.userRepo.findById(
invitation.invitedById,
workspace.id,
workspaceId,
);
if (invitedByUser) {
@ -284,9 +273,7 @@ export class WorkspaceInvitationService {
}
if (this.environmentService.isCloud()) {
await this.billingQueue.add(QueueJob.STRIPE_SEATS_SYNC, {
workspaceId: workspace.id,
});
await this.billingQueue.add(QueueJob.STRIPE_SEATS_SYNC, { workspaceId });
}
return this.tokenService.generateAccessToken(newUser);
@ -317,7 +304,7 @@ export class WorkspaceInvitationService {
invitation.email,
invitation.token,
invitedByUser.name,
workspace,
workspace.hostname,
);
}
@ -340,17 +327,17 @@ export class WorkspaceInvitationService {
return this.buildInviteLink({
invitationId,
inviteToken: token.token,
workspace: workspace,
hostname: workspace.hostname,
});
}
async buildInviteLink(opts: {
invitationId: string;
inviteToken: string;
workspace: Workspace;
hostname?: string;
}): Promise<string> {
const { invitationId, inviteToken, workspace } = opts;
return `${this.domainService.getUrl(workspace.hostname, workspace.customDomain)}/invites/${invitationId}?token=${inviteToken}`;
const { invitationId, inviteToken, hostname } = opts;
return `${this.domainService.getUrl(hostname)}/invites/${invitationId}?token=${inviteToken}`;
}
async sendInvitationMail(
@ -358,12 +345,12 @@ export class WorkspaceInvitationService {
inviteeEmail: string,
inviteToken: string,
invitedByName: string,
workspace: Workspace,
hostname?: string,
): Promise<void> {
const inviteLink = await this.buildInviteLink({
invitationId,
inviteToken,
workspace,
hostname,
});
const emailTemplate = InvitationEmail({

View File

@ -32,7 +32,6 @@ import { AttachmentType } from 'src/core/attachment/attachment.constants';
import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import { Queue } from 'bullmq';
import { generateRandomSuffixNumbers } from '../../../common/helpers';
@Injectable()
export class WorkspaceService {
@ -378,20 +377,24 @@ export class WorkspaceService {
name: string,
trx?: KyselyTransaction,
): Promise<string> {
const generateRandomSuffix = (length: number) =>
Math.random()
.toFixed(length)
.substring(2, 2 + length);
let subdomain = name
.toLowerCase()
.replace(/[^a-z0-9-]/g, '')
.substring(0, 20)
.replace(/^-+|-+$/g, ''); //remove any hyphen at the start or end
.replace(/[^a-z0-9]/g, '')
.substring(0, 20);
// Ensure we leave room for a random suffix.
const maxSuffixLength = 6;
if (subdomain.length < 4) {
subdomain = `${subdomain}-${generateRandomSuffixNumbers(maxSuffixLength)}`;
subdomain = `${subdomain}-${generateRandomSuffix(maxSuffixLength)}`;
}
if (DISALLOWED_HOSTNAMES.includes(subdomain)) {
subdomain = `workspace-${generateRandomSuffixNumbers(maxSuffixLength)}`;
subdomain = `workspace-${generateRandomSuffix(maxSuffixLength)}`;
}
let uniqueHostname = subdomain;
@ -405,7 +408,7 @@ export class WorkspaceService {
break;
}
// Append a random suffix and retry.
const randomSuffix = generateRandomSuffixNumbers(maxSuffixLength);
const randomSuffix = generateRandomSuffix(maxSuffixLength);
uniqueHostname = `${subdomain}-${randomSuffix}`.substring(0, 25);
}

View File

@ -1,23 +0,0 @@
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();
}

View File

@ -83,14 +83,6 @@ export class WorkspaceRepo {
.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(
hostname: string,
trx?: KyselyTransaction,

View File

@ -84,7 +84,6 @@ export interface Backlinks {
export interface Billing {
amount: Int8 | null;
billingScheme: string | null;
cancelAt: Timestamp | null;
cancelAtPeriodEnd: boolean | null;
canceledAt: Timestamp | null;
@ -97,7 +96,6 @@ export interface Billing {
metadata: Json | null;
periodEndAt: Timestamp | null;
periodStartAt: Timestamp;
planName: string | null;
quantity: Int8 | null;
status: string;
stripeCustomerId: string | null;
@ -105,9 +103,6 @@ export interface Billing {
stripePriceId: string | null;
stripeProductId: string | null;
stripeSubscriptionId: string;
tieredFlatAmount: Int8 | null;
tieredUnitAmount: Int8 | null;
tieredUpTo: string | null;
updatedAt: Generated<Timestamp>;
workspaceId: string;
}

View File

@ -5,13 +5,10 @@ import { EnvironmentService } from './environment.service';
export class DomainService {
constructor(private environmentService: EnvironmentService) {}
getUrl(hostname?: string, customDomain?: string): string {
getUrl(hostname?: string): string {
if (!this.environmentService.isCloud()) {
return this.environmentService.getAppUrl();
}
if (customDomain) {
return customDomain;
}
const domain = this.environmentService.getSubdomainHost();
if (!hostname || !domain) {

View File

@ -1,6 +1,5 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import ms, { StringValue } from 'ms';
@Injectable()
export class EnvironmentService {
@ -57,18 +56,7 @@ export class EnvironmentService {
}
getJwtTokenExpiresIn(): string {
return this.configService.get<string>('JWT_TOKEN_EXPIRES_IN', '90d');
}
getCookieExpiresIn(): Date {
const expiresInStr = this.getJwtTokenExpiresIn();
let msUntilExpiry: number;
try {
msUntilExpiry = ms(expiresInStr as StringValue);
} catch (err) {
msUntilExpiry = ms('90d');
}
return new Date(Date.now() + msUntilExpiry);
return this.configService.get<string>('JWT_TOKEN_EXPIRES_IN', '30d');
}
getStorageDriver(): string {
@ -205,12 +193,4 @@ export class EnvironmentService {
.toLowerCase();
return disable === 'true';
}
getPostHogHost(): string {
return this.configService.get<string>('POSTHOG_HOST');
}
getPostHogKey(): string {
return this.configService.get<string>('POSTHOG_KEY');
}
}

View File

@ -68,10 +68,6 @@ export class EnvironmentVariables {
)
@ValidateIf((obj) => obj.CLOUD === 'true'.toLowerCase())
SUBDOMAIN_HOST: string;
@IsOptional()
@ValidateIf((obj) => obj.CLOUD === 'true'.toLowerCase())
APP_IP: string;
}
export function validate(config: Record<string, any>) {

View File

@ -37,8 +37,6 @@ export class StaticModule implements OnModuleInit {
CLOUD: this.environmentService.isCloud(),
FILE_UPLOAD_SIZE_LIMIT:
this.environmentService.getFileUploadSizeLimit(),
FILE_IMPORT_SIZE_LIMIT:
this.environmentService.getFileImportSizeLimit(),
DRAWIO_URL: this.environmentService.getDrawioUrl(),
SUBDOMAIN_HOST: this.environmentService.isCloud()
? this.environmentService.getSubdomainHost()
@ -47,8 +45,6 @@ export class StaticModule implements OnModuleInit {
BILLING_TRIAL_DAYS: this.environmentService.isCloud()
? this.environmentService.getBillingTrialDays()
: undefined,
POSTHOG_HOST: this.environmentService.getPostHogHost(),
POSTHOG_KEY: this.environmentService.getPostHogKey(),
};
const windowScriptContent = `<script>window.CONFIG=${JSON.stringify(configString)};</script>`;

View File

@ -1,7 +1,7 @@
{
"name": "docmost",
"homepage": "https://docmost.com",
"version": "0.21.0",
"version": "0.20.4",
"private": true,
"scripts": {
"build": "nx run-many -t build",
@ -69,7 +69,6 @@
"jszip": "^3.10.1",
"linkifyjs": "^4.2.0",
"marked": "13.0.3",
"ms": "3.0.0-canary.1",
"uuid": "^11.1.0",
"y-indexeddb": "^9.0.12",
"yjs": "^13.6.27"

66
pnpm-lock.yaml generated
View File

@ -169,9 +169,6 @@ importers:
marked:
specifier: 13.0.3
version: 13.0.3
ms:
specifier: 3.0.0-canary.1
version: 3.0.0-canary.1
uuid:
specifier: ^11.1.0
version: 11.1.0
@ -248,9 +245,6 @@ importers:
'@tiptap/extension-character-count':
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)
alfaaz:
specifier: ^1.1.0
version: 1.1.0
axios:
specifier: ^1.9.0
version: 1.9.0
@ -296,9 +290,6 @@ importers:
mitt:
specifier: ^3.0.1
version: 3.0.1
posthog-js:
specifier: ^1.255.1
version: 1.255.1
react:
specifier: ^18.3.1
version: 18.3.1
@ -567,9 +558,6 @@ importers:
stripe:
specifier: ^17.5.0
version: 17.5.0
tld-extract:
specifier: ^2.1.0
version: 2.1.0
tmp-promise:
specifier: ^3.0.3
version: 3.0.3
@ -4689,9 +4677,6 @@ packages:
ajv@8.17.1:
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
alfaaz@1.1.0:
resolution: {integrity: sha512-J/P07R41APslK7NmD5303bwStN8jpRA4DdvtLeAr1Jhfj6XWGrASUWI0G6jbWjJAZyw3Lu1Pb4J8rsM/cb+xDQ==}
ansi-align@3.0.1:
resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==}
@ -5219,9 +5204,6 @@ packages:
core-js-compat@3.35.0:
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:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
@ -5997,9 +5979,6 @@ packages:
picomatch:
optional: true
fflate@0.4.8:
resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==}
fflate@0.8.2:
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
@ -7379,10 +7358,6 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
ms@3.0.0-canary.1:
resolution: {integrity: sha512-kh8ARjh8rMN7Du2igDRO9QJnqCb2xYTJxyQYK7vJJS4TvLLmsbyhiKpSW+t+y26gyOyMd0riphX0GeWKU3ky5g==}
engines: {node: '>=12.13'}
msgpackr-extract@3.0.2:
resolution: {integrity: sha512-SdzXp4kD/Qf8agZ9+iTu6eql0m3kWm1A2y1hkpTeVNENutaB0BwHlSvAIaMxwntmRUAUjon2V4L8Z/njd0Ct8A==}
hasBin: true
@ -7967,23 +7942,9 @@ packages:
postgres-range@1.1.4:
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:
resolution: {integrity: sha512-nerZdd3TwOH4CgGboZnlUM/q7oZk0EqpZgJL+Y3Nup8kHeaukxouQ6JcFF3EJEijc4QbuNv1TefGhboAKtf/SQ==}
preact@10.26.9:
resolution: {integrity: sha512-SSjF9vcnF27mJK1XyFMNJzFd5u3pQiATFqoaDy03XuN00u4ziveVVEGt5RKJrDR8MHE/wJo9Nnad56RLzS2RMA==}
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@ -8867,9 +8828,6 @@ packages:
tiptap-extension-global-drag-handle@0.1.18:
resolution: {integrity: sha512-jwFuy1K8DP3a4bFy76Hpc63w1Sil0B7uZ3mvhQomVvUFCU787Lg2FowNhn7NFzeyok761qY2VG+PZ/FDthWUdg==}
tld-extract@2.1.0:
resolution: {integrity: sha512-Y9QHWIoDQPJJVm3/pOC7kOfOj7vsNSVZl4JGoEHb605FiwZgIfzSMyU0HC0wYw5Cx8435vaG1yGZtIm1yiQGOw==}
tldts-core@6.1.72:
resolution: {integrity: sha512-FW3H9aCaGTJ8l8RVCR3EX8GxsxDbQXuwetwwgXA2chYdsX+NY1ytCBl61narjjehWmCw92tc1AxlcY3668CU8g==}
@ -9326,9 +9284,6 @@ packages:
wcwidth@1.0.1:
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
web-vitals@4.2.4:
resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==}
web-worker@1.5.0:
resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==}
@ -14594,8 +14549,6 @@ snapshots:
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
alfaaz@1.1.0: {}
ansi-align@3.0.1:
dependencies:
string-width: 4.2.3
@ -15226,8 +15179,6 @@ snapshots:
dependencies:
browserslist: 4.24.2
core-js@3.43.0: {}
core-util-is@1.0.3: {}
cors@2.8.5:
@ -16215,8 +16166,6 @@ snapshots:
optionalDependencies:
picomatch: 4.0.2
fflate@0.4.8: {}
fflate@0.8.2: {}
figures@3.2.0:
@ -17895,8 +17844,6 @@ snapshots:
ms@2.1.3: {}
ms@3.0.0-canary.1: {}
msgpackr-extract@3.0.2:
dependencies:
node-gyp-build-optional-packages: 5.0.7
@ -18518,21 +18465,12 @@ snapshots:
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:
dependencies:
axios: 1.9.0
transitivePeerDependencies:
- debug
preact@10.26.9: {}
prelude-ls@1.2.1: {}
prettier@3.4.1: {}
@ -19544,8 +19482,6 @@ snapshots:
tiptap-extension-global-drag-handle@0.1.18: {}
tld-extract@2.1.0: {}
tldts-core@6.1.72: {}
tldts@6.1.72:
@ -19958,8 +19894,6 @@ snapshots:
dependencies:
defaults: 1.0.4
web-vitals@4.2.4: {}
web-worker@1.5.0: {}
webidl-conversions@3.0.1: {}