mirror of
https://github.com/docmost/docmost.git
synced 2026-05-08 15:23:07 +08:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d275968bc | |||
| 62a2eb61ea | |||
| 232cea8cc9 | |||
| b9643d3584 | |||
| 9f144d35fb | |||
| e44c170873 | |||
| 1be39d4353 | |||
| 36d028ef4d | |||
| f5a36c60e8 | |||
| d5b84ae0b8 | |||
| e775e4dd8c |
@@ -41,6 +41,7 @@
|
||||
"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",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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 (
|
||||
@@ -8,6 +10,7 @@ export default function Layout() {
|
||||
<GlobalAppShell>
|
||||
<Outlet />
|
||||
</GlobalAppShell>
|
||||
{isCloud() && <PosthogUser />}
|
||||
</UserProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,12 +30,12 @@ export default function BillingDetails() {
|
||||
>
|
||||
Plan
|
||||
</Text>
|
||||
<Text fw={700} fz="lg">
|
||||
{
|
||||
plans.find(
|
||||
(plan) => plan.productId === billing.stripeProductId,
|
||||
)?.name
|
||||
}
|
||||
<Text fw={700} fz="lg" tt="capitalize">
|
||||
{plans.find(
|
||||
(plan) => plan.productId === billing.stripeProductId,
|
||||
)?.name ||
|
||||
billing.planName ||
|
||||
"Standard"}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
@@ -112,18 +112,58 @@ export default function BillingDetails() {
|
||||
fz="xs"
|
||||
className={classes.label}
|
||||
>
|
||||
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}
|
||||
Cost
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -2,24 +2,28 @@ 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 { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
|
||||
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
|
||||
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
|
||||
|
||||
export default function BillingPlans() {
|
||||
const { data: plans } = useBillingPlans();
|
||||
const [interval, setInterval] = useState("yearly");
|
||||
|
||||
if (!plans) {
|
||||
return null;
|
||||
}
|
||||
const [isAnnual, setIsAnnual] = useState(true);
|
||||
const [selectedTierValue, setSelectedTierValue] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const handleCheckout = async (priceId: string) => {
|
||||
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 (
|
||||
<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;
|
||||
<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}
|
||||
/>
|
||||
|
||||
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" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<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"
|
||||
<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"
|
||||
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>
|
||||
/>
|
||||
<Text size="md">
|
||||
Annually
|
||||
<Badge component="span" variant="light" color="blue">
|
||||
15% OFF
|
||||
</Badge>
|
||||
</Text>
|
||||
</Flex>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
{/* 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
|
||||
</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;
|
||||
updatedAt: Date;
|
||||
deletedAt: Date;
|
||||
billingScheme: string | null;
|
||||
tieredUpTo: string | null;
|
||||
tieredFlatAmount: number | null;
|
||||
tieredUnitAmount: number | null;
|
||||
planName: string | null;
|
||||
}
|
||||
|
||||
export interface ICheckoutLink {
|
||||
@@ -42,9 +47,18 @@ 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;
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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={() => {
|
||||
editor.commands.unsetColor();
|
||||
name !== "Default" &&
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setColor(color || "")
|
||||
.run();
|
||||
if (name === "Default") {
|
||||
editor.commands.unsetColor();
|
||||
} else {
|
||||
editor.chain().focus().setColor(color || "").run();
|
||||
}
|
||||
setIsOpen(false);
|
||||
}}
|
||||
style={{ border: "none" }}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import "@/features/editor/styles/index.css";
|
||||
import React, {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -72,7 +71,11 @@ export default function PageEditor({
|
||||
const [, setAsideState] = useAtom(asideStateAtom);
|
||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||
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 [isRemoteSynced, setRemoteSynced] = useState(false);
|
||||
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||
@@ -89,66 +92,100 @@ export default function PageEditor({
|
||||
const userPageEditMode =
|
||||
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
||||
|
||||
const localProvider = useMemo(() => {
|
||||
const provider = new IndexeddbPersistence(documentName, ydoc);
|
||||
// Providers only created once per pageId
|
||||
const providersRef = useRef<{
|
||||
local: IndexeddbPersistence;
|
||||
remote: HocuspocusProvider;
|
||||
} | null>(null);
|
||||
const [providersReady, setProvidersReady] = useState(false);
|
||||
|
||||
provider.on("synced", () => {
|
||||
setLocalSynced(true);
|
||||
});
|
||||
const localProvider = providersRef.current?.local;
|
||||
const remoteProvider = providersRef.current?.remote;
|
||||
|
||||
return provider;
|
||||
}, [pageId, ydoc]);
|
||||
// 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]);
|
||||
|
||||
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();
|
||||
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
|
||||
return () => {
|
||||
setRemoteSynced(false);
|
||||
setLocalSynced(false);
|
||||
remoteProvider.destroy();
|
||||
localProvider.destroy();
|
||||
providersRef.current?.remote.destroy();
|
||||
providersRef.current?.local.destroy();
|
||||
providersRef.current = null;
|
||||
};
|
||||
}, [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(() => {
|
||||
if (!remoteProvider || !currentUser?.user) return mainExtensions;
|
||||
return [
|
||||
...mainExtensions,
|
||||
...collabExtensions(remoteProvider, currentUser?.user),
|
||||
];
|
||||
}, [ydoc, pageId, remoteProvider, currentUser?.user]);
|
||||
}, [remoteProvider, currentUser?.user]);
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
@@ -202,7 +239,7 @@ export default function PageEditor({
|
||||
debouncedUpdateContent(editorJson);
|
||||
},
|
||||
},
|
||||
[pageId, editable, remoteProvider?.status],
|
||||
[pageId, editable, remoteProvider],
|
||||
);
|
||||
|
||||
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
|
||||
@@ -255,29 +292,6 @@ 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(() => {
|
||||
@@ -294,21 +308,48 @@ export default function PageEditor({
|
||||
}, [isRemoteSynced, isLocalSynced, remoteProvider?.status]);
|
||||
|
||||
useEffect(() => {
|
||||
// honor user default page edit mode preference
|
||||
if (userPageEditMode && editor && editable && isSynced) {
|
||||
if (userPageEditMode === PageEditMode.Edit) {
|
||||
editor.setEditable(true);
|
||||
} else if (userPageEditMode === PageEditMode.Read) {
|
||||
// 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, isSynced]);
|
||||
}, [userPageEditMode, editor, editable]);
|
||||
|
||||
return isCollabReady ? (
|
||||
<div>
|
||||
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" }}>
|
||||
<div ref={menuContainerRef}>
|
||||
<EditorContent editor={editor} />
|
||||
|
||||
{editor && editor.isEditable && (
|
||||
<div>
|
||||
<EditorBubbleMenu editor={editor} />
|
||||
@@ -322,21 +363,12 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -120,3 +120,19 @@ 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;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface IWorkspace {
|
||||
settings: any;
|
||||
status: string;
|
||||
enforceSso: boolean;
|
||||
stripeCustomerId: string;
|
||||
billingEmail: string;
|
||||
trialEndAt: Date;
|
||||
createdAt: Date;
|
||||
|
||||
@@ -83,6 +83,18 @@ 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]
|
||||
|
||||
@@ -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,6 +11,14 @@ 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: {
|
||||
@@ -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(
|
||||
document.getElementById("root") as HTMLElement
|
||||
document.getElementById("root") as HTMLElement,
|
||||
);
|
||||
|
||||
root.render(
|
||||
@@ -35,10 +50,12 @@ root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Notifications position="bottom-center" limit={3} />
|
||||
<HelmetProvider>
|
||||
<App />
|
||||
<PostHogProvider client={posthog}>
|
||||
<App />
|
||||
</PostHogProvider>
|
||||
</HelmetProvider>
|
||||
</QueryClientProvider>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
</BrowserRouter>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ 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();
|
||||
@@ -20,6 +21,8 @@ export default function WorkspaceSettings() {
|
||||
<>
|
||||
<Divider my="md" />
|
||||
<ManageHostname />
|
||||
<Divider my="md" />
|
||||
<ManageCustomDomain />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -14,6 +14,8 @@ export default defineConfig(({ mode }) => {
|
||||
SUBDOMAIN_HOST,
|
||||
COLLAB_URL,
|
||||
BILLING_TRIAL_DAYS,
|
||||
POSTHOG_HOST,
|
||||
POSTHOG_KEY,
|
||||
} = loadEnv(mode, envPath, "");
|
||||
|
||||
return {
|
||||
@@ -27,6 +29,8 @@ export default defineConfig(({ mode }) => {
|
||||
SUBDOMAIN_HOST,
|
||||
COLLAB_URL,
|
||||
BILLING_TRIAL_DAYS,
|
||||
POSTHOG_HOST,
|
||||
POSTHOG_KEY,
|
||||
},
|
||||
APP_VERSION: JSON.stringify(process.env.npm_package_version),
|
||||
},
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
"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"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NestMiddleware, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, NestMiddleware } 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,8 +27,19 @@ export class DomainMiddleware implements NestMiddleware {
|
||||
(req as any).workspace = workspace;
|
||||
} else if (this.environmentService.isCloud()) {
|
||||
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);
|
||||
|
||||
if (!workspace) {
|
||||
|
||||
@@ -134,7 +134,7 @@ export class AuthService {
|
||||
|
||||
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({
|
||||
token: token,
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
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';
|
||||
|
||||
export class UpdateUserDto extends PartialType(
|
||||
@@ -21,4 +29,10 @@ export class UpdateUserDto extends PartialType(
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
locale: string;
|
||||
|
||||
@IsOptional()
|
||||
@MinLength(8)
|
||||
@MaxLength(70)
|
||||
@IsString()
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,6 @@ export class UserController {
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return this.userService.update(updateUserDto, user.id, workspace.id);
|
||||
return this.userService.update(updateUserDto, user.id, workspace);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,12 @@ 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 {
|
||||
@@ -17,9 +21,14 @@ export class UserService {
|
||||
async update(
|
||||
updateUserDto: UpdateUserDto,
|
||||
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) {
|
||||
throw new NotFoundException('User not found');
|
||||
@@ -47,9 +56,27 @@ export class UserService {
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
user.email = updateUserDto.email;
|
||||
}
|
||||
|
||||
@@ -61,7 +88,9 @@ export class UserService {
|
||||
user.locale = updateUserDto.locale;
|
||||
}
|
||||
|
||||
await this.userRepo.updateUser(updateUserDto, userId, workspaceId);
|
||||
delete updateUserDto.confirmPassword;
|
||||
|
||||
await this.userRepo.updateUser(updateUserDto, userId, workspace.id);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ export class WorkspaceInvitationService {
|
||||
invitation.email,
|
||||
invitation.token,
|
||||
authUser.name,
|
||||
workspace.hostname,
|
||||
workspace,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -317,7 +317,7 @@ export class WorkspaceInvitationService {
|
||||
invitation.email,
|
||||
invitation.token,
|
||||
invitedByUser.name,
|
||||
workspace.hostname,
|
||||
workspace,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -340,17 +340,17 @@ export class WorkspaceInvitationService {
|
||||
return this.buildInviteLink({
|
||||
invitationId,
|
||||
inviteToken: token.token,
|
||||
hostname: workspace.hostname,
|
||||
workspace: workspace,
|
||||
});
|
||||
}
|
||||
|
||||
async buildInviteLink(opts: {
|
||||
invitationId: string;
|
||||
inviteToken: string;
|
||||
hostname?: string;
|
||||
workspace: Workspace;
|
||||
}): Promise<string> {
|
||||
const { invitationId, inviteToken, hostname } = opts;
|
||||
return `${this.domainService.getUrl(hostname)}/invites/${invitationId}?token=${inviteToken}`;
|
||||
const { invitationId, inviteToken, workspace } = opts;
|
||||
return `${this.domainService.getUrl(workspace.hostname, workspace.customDomain)}/invites/${invitationId}?token=${inviteToken}`;
|
||||
}
|
||||
|
||||
async sendInvitationMail(
|
||||
@@ -358,12 +358,12 @@ export class WorkspaceInvitationService {
|
||||
inviteeEmail: string,
|
||||
inviteToken: string,
|
||||
invitedByName: string,
|
||||
hostname?: string,
|
||||
workspace: Workspace,
|
||||
): Promise<void> {
|
||||
const inviteLink = await this.buildInviteLink({
|
||||
invitationId,
|
||||
inviteToken,
|
||||
hostname,
|
||||
workspace,
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
+5
@@ -84,6 +84,7 @@ export interface Backlinks {
|
||||
|
||||
export interface Billing {
|
||||
amount: Int8 | null;
|
||||
billingScheme: string | null;
|
||||
cancelAt: Timestamp | null;
|
||||
cancelAtPeriodEnd: boolean | null;
|
||||
canceledAt: Timestamp | null;
|
||||
@@ -96,6 +97,7 @@ export interface Billing {
|
||||
metadata: Json | null;
|
||||
periodEndAt: Timestamp | null;
|
||||
periodStartAt: Timestamp;
|
||||
planName: string | null;
|
||||
quantity: Int8 | null;
|
||||
status: string;
|
||||
stripeCustomerId: string | null;
|
||||
@@ -103,6 +105,9 @@ 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;
|
||||
}
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: ffcae8dbe7...1a02886f1f
@@ -5,10 +5,13 @@ import { EnvironmentService } from './environment.service';
|
||||
export class DomainService {
|
||||
constructor(private environmentService: EnvironmentService) {}
|
||||
|
||||
getUrl(hostname?: string): string {
|
||||
getUrl(hostname?: string, customDomain?: string): string {
|
||||
if (!this.environmentService.isCloud()) {
|
||||
return this.environmentService.getAppUrl();
|
||||
}
|
||||
if (customDomain) {
|
||||
return customDomain;
|
||||
}
|
||||
|
||||
const domain = this.environmentService.getSubdomainHost();
|
||||
if (!hostname || !domain) {
|
||||
|
||||
@@ -205,4 +205,12 @@ 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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,10 @@ 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>) {
|
||||
|
||||
@@ -47,6 +47,8 @@ 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>`;
|
||||
|
||||
Generated
+49
@@ -296,6 +296,9 @@ 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
|
||||
@@ -564,6 +567,9 @@ 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
|
||||
@@ -5213,6 +5219,9 @@ 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==}
|
||||
|
||||
@@ -5988,6 +5997,9 @@ 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==}
|
||||
|
||||
@@ -7955,9 +7967,23 @@ 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'}
|
||||
@@ -8841,6 +8867,9 @@ 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==}
|
||||
|
||||
@@ -9297,6 +9326,9 @@ 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==}
|
||||
|
||||
@@ -15194,6 +15226,8 @@ snapshots:
|
||||
dependencies:
|
||||
browserslist: 4.24.2
|
||||
|
||||
core-js@3.43.0: {}
|
||||
|
||||
core-util-is@1.0.3: {}
|
||||
|
||||
cors@2.8.5:
|
||||
@@ -16181,6 +16215,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.2
|
||||
|
||||
fflate@0.4.8: {}
|
||||
|
||||
fflate@0.8.2: {}
|
||||
|
||||
figures@3.2.0:
|
||||
@@ -18482,12 +18518,21 @@ 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: {}
|
||||
@@ -19499,6 +19544,8 @@ snapshots:
|
||||
|
||||
tiptap-extension-global-drag-handle@0.1.18: {}
|
||||
|
||||
tld-extract@2.1.0: {}
|
||||
|
||||
tldts-core@6.1.72: {}
|
||||
|
||||
tldts@6.1.72:
|
||||
@@ -19911,6 +19958,8 @@ snapshots:
|
||||
dependencies:
|
||||
defaults: 1.0.4
|
||||
|
||||
web-vitals@4.2.4: {}
|
||||
|
||||
web-worker@1.5.0: {}
|
||||
|
||||
webidl-conversions@3.0.1: {}
|
||||
|
||||
Reference in New Issue
Block a user