mirror of
https://github.com/docmost/docmost.git
synced 2025-11-10 22:22:05 +10:00
Compare commits
2 Commits
collab-aut
...
feat/custo
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d275968bc | |||
| 62a2eb61ea |
310
apps/client/src/ee/components/manage-custom-domain.tsx
Normal file
310
apps/client/src/ee/components/manage-custom-domain.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
|
||||
Submodule apps/server/src/ee updated: 19197d2610...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) {
|
||||
|
||||
@ -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>) {
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@ -567,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
|
||||
@ -8864,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==}
|
||||
|
||||
@ -19538,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:
|
||||
|
||||
Reference in New Issue
Block a user