feat: cloud and ee (#805)

* stripe init
git submodules for enterprise modules

* * Cloud billing UI - WIP
* Proxy websockets in dev mode
* Separate workspace login and creation for cloud
* Other fixes

* feat: billing (cloud)

* * add domain service
* prepare links from workspace hostname

* WIP

* Add exchange token generation
* Validate JWT token type during verification

* domain service

* add SkipTransform decorator

* * updates (server)
* add new packages
* new sso migration file

* WIP

* Fix hostname generation

* WIP

* WIP

* Reduce input error font-size
* set max password length

* jwt package

* license page - WIP

* * License management UI
* Move license key store to db

* add reflector

* SSO enforcement

* * Add default plan
* Add usePlan hook

* * Fix auth container margin in mobile
* Redirect login and home to select page in cloud

* update .gitignore

* Default to yearly

* * Trial messaging
* Handle ended trials

* Don't set to readonly on collab disconnect (Cloud)

* Refine trial (UI)
* Fix bug caused by using jotai optics atom in AppHeader component

* configurable database maximum pool

* Close SSO form on save

* wip

* sync

* Only show sign-in in cloud

* exclude base api part from workspaceId check

* close db connection beforeApplicationShutdown

* Add health/live endpoint

* clear cookie on hostname change

* reset currentUser atom

* Change text

* return 401 if workspace does not match

* feat: show user workspace list in cloud login page

* sync

* Add home path

* Prefetch to speed up queries

* * Add robots.txt
* Disallow login and forgot password routes

* wildcard user-agent

* Fix space query cache

* fix

* fix

* use space uuid for recent pages

* prefetch billing plans

* enhance license page

* sync
This commit is contained in:
Philip Okugbe 2025-03-06 13:38:37 +00:00 committed by GitHub
parent 91596be70e
commit b81c9ee10c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
148 changed files with 8947 additions and 3458 deletions

2
.gitignore vendored
View File

@ -1,4 +1,6 @@
.env
.env.dev
.env.prod
data
# compiled output
/dist

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "apps/server/src/ee"]
path = apps/server/src/ee
url = https://github.com/docmost/ee

View File

@ -16,12 +16,12 @@
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "^0.17.6",
"@mantine/core": "^7.14.2",
"@mantine/form": "^7.14.2",
"@mantine/hooks": "^7.14.2",
"@mantine/modals": "^7.14.2",
"@mantine/notifications": "^7.14.2",
"@mantine/spotlight": "^7.14.2",
"@mantine/core": "^7.17.0",
"@mantine/form": "^7.17.0",
"@mantine/hooks": "^7.17.0",
"@mantine/modals": "^7.17.0",
"@mantine/notifications": "^7.17.0",
"@mantine/spotlight": "^7.17.0",
"@tabler/icons-react": "^3.22.0",
"@tanstack/react-query": "^5.61.4",
"axios": "^1.7.9",
@ -30,7 +30,7 @@
"file-saver": "^2.0.5",
"i18next": "^23.14.0",
"i18next-http-backend": "^2.6.1",
"jotai": "^2.10.3",
"jotai": "^2.12.1",
"jotai-optics": "^0.4.0",
"js-cookie": "^3.0.5",
"katex": "0.16.21",

View File

@ -18,10 +18,18 @@ import { ErrorBoundary } from "react-error-boundary";
import InviteSignup from "@/pages/auth/invite-signup.tsx";
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
import PasswordReset from "./pages/auth/password-reset";
import Billing from "@/ee/billing/pages/billing.tsx";
import CloudLogin from "@/ee/pages/cloud-login.tsx";
import CreateWorkspace from "@/ee/pages/create-workspace.tsx";
import { isCloud } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import Security from "@/ee/security/pages/security.tsx";
import License from "@/ee/licence/pages/license.tsx";
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
export default function App() {
const { t } = useTranslation();
useRedirectToCloudSelect();
return (
<>
@ -29,15 +37,24 @@ export default function App() {
<Route index element={<Navigate to="/home" />} />
<Route path={"/login"} element={<LoginPage />} />
<Route path={"/invites/:invitationId"} element={<InviteSignup />} />
<Route path={"/setup/register"} element={<SetupWorkspace />} />
<Route path={"/forgot-password"} element={<ForgotPassword />} />
<Route path={"/password-reset"} element={<PasswordReset />} />
{!isCloud() && (
<Route path={"/setup/register"} element={<SetupWorkspace />} />
)}
{isCloud() && (
<>
<Route path={"/create"} element={<CreateWorkspace />} />
<Route path={"/select"} element={<CloudLogin />} />
</>
)}
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
<Route element={<Layout />}>
<Route path={"/home"} element={<Home />} />
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
<Route
path={"/s/:spaceSlug/p/:pageSlug"}
@ -61,6 +78,9 @@ export default function App() {
<Route path={"groups"} element={<Groups />} />
<Route path={"groups/:groupId"} element={<GroupInfo />} />
<Route path={"spaces"} element={<Spaces />} />
<Route path={"security"} element={<Security />} />
{!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />}
</Route>
</Route>

View File

@ -0,0 +1,31 @@
import { ActionIcon, CopyButton, Tooltip } from "@mantine/core";
import { IconCheck, IconCopy } from "@tabler/icons-react";
import React from "react";
import { useTranslation } from "react-i18next";
interface CopyProps {
text: string;
}
export default function CopyTextButton({ text }: CopyProps) {
const { t } = useTranslation();
return (
<CopyButton value={text} timeout={2000}>
{({ copied, copy }) => (
<Tooltip
label={copied ? t("Copied") : t("Copy")}
withArrow
position="right"
>
<ActionIcon
color={copied ? "teal" : "gray"}
variant="subtle"
onClick={copy}
>
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
);
}

View File

@ -0,0 +1,33 @@
import { rem } from "@mantine/core";
interface Props {
size?: number | string;
}
export function GoogleIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid"
viewBox="0 0 256 262"
style={{ width: rem(size), height: rem(size) }}
>
<path
fill="#4285F4"
d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622 38.755 30.023 2.685.268c24.659-22.774 38.875-56.282 38.875-96.027"
/>
<path
fill="#34A853"
d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055-34.523 0-63.824-22.773-74.269-54.25l-1.531.13-40.298 31.187-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1"
/>
<path
fill="#FBBC05"
d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82 0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602l42.356-32.782"
/>
<path
fill="#EB4335"
d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0 79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251"
/>
</svg>
);
}

View File

@ -0,0 +1,20 @@
import { rem } from "@mantine/core";
interface Props {
size?: number | string;
}
export function OpenIdIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{ width: rem(size), height: rem(size) }}
>
<path d="M14.54.889l-3.63 1.773v18.17c-4.15-.52-7.27-2.78-7.27-5.5 0-2.58 2.8-4.75 6.63-5.41v-2.31C4.42 8.322 0 11.502 0 15.332c0 3.96 4.74 7.24 10.91 7.78l3.63-1.71V.888m.64 6.724v2.31c1.43.25 2.71.7 3.76 1.31l-1.97 1.11 7.03 1.53-.5-5.21-1.87 1.06c-1.74-1.06-3.96-1.81-6.45-2.11z" />
</svg>
);
}

View File

@ -1,19 +1,21 @@
import {Group, Text, Tooltip} from "@mantine/core";
import { Badge, Group, Text, Tooltip } from "@mantine/core";
import classes from "./app-header.module.css";
import React from "react";
import TopMenu from "@/components/layouts/global/top-menu.tsx";
import {Link} from "react-router-dom";
import { Link } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route.ts";
import {useAtom} from "jotai/index";
import { useAtom } from "jotai";
import {
desktopSidebarAtom,
mobileSidebarAtom,
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import {useToggleSidebar} from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
import { useTranslation } from "react-i18next";
import useTrial from "@/ee/hooks/use-trial.tsx";
import { isCloud } from "@/lib/config.ts";
const links = [{link: APP_ROUTE.HOME, label: "Home"}];
const links = [{ link: APP_ROUTE.HOME, label: "Home" }];
export function AppHeader() {
const { t } = useTranslation();
@ -22,6 +24,7 @@ export function AppHeader() {
const [desktopOpened] = useAtom(desktopSidebarAtom);
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
const { isTrial, trialDaysLeft } = useTrial();
const isHomeRoute = location.pathname.startsWith("/home");
@ -38,7 +41,6 @@ export function AppHeader() {
{!isHomeRoute && (
<>
<Tooltip label={t("Sidebar toggle")}>
<SidebarToggle
aria-label={t("Sidebar toggle")}
opened={mobileOpened}
@ -63,7 +65,7 @@ export function AppHeader() {
<Text
size="lg"
fw={600}
style={{cursor: "pointer", userSelect: "none"}}
style={{ cursor: "pointer", userSelect: "none" }}
component={Link}
to="/home"
>
@ -75,8 +77,21 @@ export function AppHeader() {
</Group>
</Group>
<Group px={"xl"}>
<TopMenu/>
<Group px={"xl"} wrap="nowrap">
{isCloud() && isTrial && trialDaysLeft !== 0 && (
<Badge
variant="light"
style={{ cursor: "pointer" }}
component={Link}
to={APP_ROUTE.SETTINGS.WORKSPACE.BILLING}
visibleFrom="xs"
>
{trialDaysLeft === 1
? "1 day left"
: `${trialDaysLeft} days left`}
</Badge>
)}
<TopMenu />
</Group>
</Group>
</>

View File

@ -1,23 +1,26 @@
import { AppShell, Container } from "@mantine/core";
import React, { useCallback, useEffect, useRef, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
import { useAtom } from "jotai";
import {
asideStateAtom,
desktopSidebarAtom,
mobileSidebarAtom, sidebarWidthAtom,
mobileSidebarAtom,
sidebarWidthAtom,
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
import Aside from "@/components/layouts/global/aside.tsx";
import classes from "./app-shell.module.css";
import { useTrialEndAction } from "@/ee/hooks/use-trial-end-action.tsx";
export default function GlobalAppShell({
children,
}: {
children: React.ReactNode;
}) {
useTrialEndAction();
const [mobileOpened] = useAtom(mobileSidebarAtom);
const [desktopOpened] = useAtom(desktopSidebarAtom);
const [{ isAsideOpen }] = useAtom(asideStateAtom);
@ -37,7 +40,9 @@ export default function GlobalAppShell({
const resize = React.useCallback(
(mouseMoveEvent) => {
if (isResizing) {
const newWidth = mouseMoveEvent.clientX - sidebarRef.current.getBoundingClientRect().left;
const newWidth =
mouseMoveEvent.clientX -
sidebarRef.current.getBoundingClientRect().left;
if (newWidth < 220) {
setSidebarWidth(220);
return;
@ -49,7 +54,7 @@ export default function GlobalAppShell({
setSidebarWidth(newWidth);
}
},
[isResizing]
[isResizing],
);
useEffect(() => {
@ -94,7 +99,11 @@ export default function GlobalAppShell({
<AppHeader />
</AppShell.Header>
{!isHomeRoute && (
<AppShell.Navbar className={classes.navbar} withBorder={false} ref={sidebarRef}>
<AppShell.Navbar
className={classes.navbar}
withBorder={false}
ref={sidebarRef}
>
<div className={classes.resizeHandle} onMouseDown={startResizing} />
{isSpaceRoute && <SpaceSidebar />}
{isSettingsRoute && <SettingsSidebar />}

View File

@ -33,13 +33,13 @@ export default function TopMenu() {
<UnstyledButton>
<Group gap={7} wrap={"nowrap"}>
<CustomAvatar
avatarUrl={workspace.logo}
name={workspace.name}
avatarUrl={workspace?.logo}
name={workspace?.name}
variant="filled"
size="sm"
/>
<Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}>
{workspace.name}
{workspace?.name}
</Text>
<IconChevronDown size={16} />
</Group>

View File

@ -0,0 +1,51 @@
import { queryClient } from "@/main.tsx";
import {
getBilling,
getBillingPlans,
} from "@/ee/billing/services/billing-service.ts";
import { getSpaces } from "@/features/space/services/space-service.ts";
import { getGroups } from "@/features/group/services/group-service.ts";
import { QueryParams } from "@/lib/types.ts";
import { getWorkspaceMembers } from "@/features/workspace/services/workspace-service.ts";
import { getLicenseInfo } from "@/ee/licence/services/license-service.ts";
export const prefetchWorkspaceMembers = () => {
const params = { limit: 100, page: 1, query: "" } as QueryParams;
queryClient.prefetchQuery({
queryKey: ["workspaceMembers", params],
queryFn: () => getWorkspaceMembers(params),
});
};
export const prefetchSpaces = () => {
queryClient.prefetchQuery({
queryKey: ["spaces", { page: 1 }],
queryFn: () => getSpaces({ page: 1 }),
});
};
export const prefetchGroups = () => {
queryClient.prefetchQuery({
queryKey: ["groups", { page: 1 }],
queryFn: () => getGroups({ page: 1 }),
});
};
export const prefetchBilling = () => {
queryClient.prefetchQuery({
queryKey: ["billing"],
queryFn: () => getBilling(),
});
queryClient.prefetchQuery({
queryKey: ["billing-plans"],
queryFn: () => getBillingPlans(),
});
};
export const prefetchLicense = () => {
queryClient.prefetchQuery({
queryKey: ["license"],
queryFn: () => getLicenseInfo(),
});
};

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { Group, Text, ScrollArea, ActionIcon, rem } from "@mantine/core";
import { Group, Text, ScrollArea, ActionIcon } from "@mantine/core";
import {
IconUser,
IconSettings,
@ -8,15 +8,33 @@ import {
IconUsersGroup,
IconSpaces,
IconBrush,
IconCoin,
IconLock,
IconKey,
} from "@tabler/icons-react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import classes from "./settings.module.css";
import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { useAtom } from "jotai/index";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import {
prefetchBilling,
prefetchGroups,
prefetchLicense,
prefetchSpaces,
prefetchWorkspaceMembers,
} from "@/components/settings/settings-queries.tsx";
interface DataItem {
label: string;
icon: React.ElementType;
path: string;
isCloud?: boolean;
isEnterprise?: boolean;
isAdmin?: boolean;
isSelfhosted?: boolean;
}
interface DataGroup {
@ -45,10 +63,35 @@ const groupedData: DataGroup[] = [
icon: IconUsers,
path: "/settings/members",
},
{
label: "Billing",
icon: IconCoin,
path: "/settings/billing",
isCloud: true,
isAdmin: true,
},
{
label: "Security & SSO",
icon: IconLock,
path: "/settings/security",
isCloud: true,
isEnterprise: true,
isAdmin: true,
},
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
],
},
{
heading: "System",
items: [
{
label: "License & Edition",
icon: IconKey,
path: "/settings/license",
},
],
},
];
export default function SettingsSidebar() {
@ -56,29 +99,92 @@ export default function SettingsSidebar() {
const location = useLocation();
const [active, setActive] = useState(location.pathname);
const navigate = useNavigate();
const { isAdmin } = useUserRole();
const [workspace] = useAtom(workspaceAtom);
useEffect(() => {
setActive(location.pathname);
}, [location.pathname]);
const menuItems = groupedData.map((group) => (
<div key={group.heading}>
<Text c="dimmed" className={classes.linkHeader}>
{t(group.heading)}
</Text>
{group.items.map((item) => (
<Link
className={classes.link}
data-active={active.startsWith(item.path) || undefined}
key={item.label}
to={item.path}
>
<item.icon className={classes.linkIcon} stroke={2} />
<span>{t(item.label)}</span>
</Link>
))}
</div>
));
const canShowItem = (item: DataItem) => {
if (item.isCloud && item.isEnterprise) {
if (!(isCloud() || workspace?.hasLicenseKey)) return false;
return item.isAdmin ? isAdmin : true;
}
if (item.isCloud) {
return isCloud() ? (item.isAdmin ? isAdmin : true) : false;
}
if (item.isSelfhosted) {
return !isCloud() ? (item.isAdmin ? isAdmin : true) : false;
}
if (item.isEnterprise) {
return workspace?.hasLicenseKey ? (item.isAdmin ? isAdmin : true) : false;
}
if (item.isAdmin) {
return isAdmin;
}
return true;
};
const menuItems = groupedData.map((group) => {
if (group.heading === "System" && (!isAdmin || isCloud())) {
return null;
}
return (
<div key={group.heading}>
<Text c="dimmed" className={classes.linkHeader}>
{t(group.heading)}
</Text>
{group.items.map((item) => {
if (!canShowItem(item)) {
return null;
}
let prefetchHandler: any;
switch (item.label) {
case "Members":
prefetchHandler = prefetchWorkspaceMembers;
break;
case "Spaces":
prefetchHandler = prefetchSpaces;
break;
case "Groups":
prefetchHandler = prefetchGroups;
break;
case "Billing":
prefetchHandler = prefetchBilling;
break;
case "License & Edition":
if (workspace?.hasLicenseKey) {
prefetchHandler = prefetchLicense;
}
break;
default:
break;
}
return (
<Link
onMouseEnter={prefetchHandler}
className={classes.link}
data-active={active.startsWith(item.path) || undefined}
key={item.label}
to={item.path}
>
<item.icon className={classes.linkIcon} stroke={2} />
<span>{t(item.label)}</span>
</Link>
);
})}
</div>
);
});
return (
<div className={classes.navbar}>
@ -95,9 +201,8 @@ export default function SettingsSidebar() {
</Group>
<ScrollArea w="100%">{menuItems}</ScrollArea>
<div className={classes.version}>
<div className={classes.text}>
<Text
className={classes.version}
size="sm"
c="dimmed"
component="a"
@ -107,6 +212,19 @@ export default function SettingsSidebar() {
v{APP_VERSION}
</Text>
</div>
{isCloud() && (
<div className={classes.text}>
<Text
size="sm"
c="dimmed"
component="a"
href="mailto:help@docmost.com"
>
help@docmost.com
</Text>
</div>
)}
</div>
);
}

View File

@ -58,7 +58,7 @@
align-items: center;
}
.version {
.text {
padding-left: var(--mantine-spacing-xs) ;
padding-top: 10px;
}

View File

@ -0,0 +1 @@
Files in this directory are subject to the Docmost Enterprise Software license.

View File

@ -0,0 +1,130 @@
import {
useBillingPlans,
useBillingQuery,
} from "@/ee/billing/queries/billing-query.ts";
import { Group, Text, SimpleGrid, Paper } from "@mantine/core";
import classes from "./billing.module.css";
import { format } from "date-fns";
import { formatInterval } from "@/ee/billing/utils.ts";
export default function BillingDetails() {
const { data: billing } = useBillingQuery();
const { data: plans } = useBillingPlans();
if (!billing || !plans) {
return null;
}
return (
<div className={classes.root}>
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }}>
<Paper p="md" radius="md">
<Group justify="apart">
<div>
<Text
c="dimmed"
tt="uppercase"
fw={700}
fz="xs"
className={classes.label}
>
Plan
</Text>
<Text fw={700} fz="lg">
{
plans.find(
(plan) => plan.productId === billing.stripeProductId,
)?.name
}
</Text>
</div>
</Group>
</Paper>
<Paper p="md" radius="md">
<Group justify="apart">
<div>
<Text
c="dimmed"
tt="uppercase"
fw={700}
fz="xs"
className={classes.label}
>
Billing Period
</Text>
<Text fw={700} fz="lg" tt="capitalize">
{formatInterval(billing.interval)}
</Text>
</div>
</Group>
</Paper>
<Paper p="md" radius="md">
<Group justify="apart">
<div>
<Text
c="dimmed"
tt="uppercase"
fw={700}
fz="xs"
className={classes.label}
>
{billing.cancelAtPeriodEnd
? "Cancellation date"
: "Renewal date"}
</Text>
<Text fw={700} fz="lg">
{format(billing.periodEndAt, "dd MMM, yyyy")}
</Text>
</div>
</Group>
</Paper>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }}>
<Paper p="md" radius="md">
<Group justify="apart">
<div>
<Text
c="dimmed"
tt="uppercase"
fw={700}
fz="xs"
className={classes.label}
>
Seat count
</Text>
<Text fw={700} fz="lg">
{billing.quantity}
</Text>
</div>
</Group>
</Paper>
<Paper p="md" radius="md">
<Group justify="apart">
<div>
<Text
c="dimmed"
tt="uppercase"
fw={700}
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}
</Text>
</div>
</Group>
</Paper>
</SimpleGrid>
</div>
);
}

View File

@ -0,0 +1,13 @@
import { Alert } from "@mantine/core";
import React from "react";
export default function BillingIncomplete() {
return (
<>
<Alert variant="light" color="blue">
Your subscription is in an incomplete state. Please refresh this page if
you recently made your payment.
</Alert>
</>
);
}

View File

@ -0,0 +1,115 @@
import {
Button,
Card,
List,
SegmentedControl,
ThemeIcon,
Title,
Text,
Group,
} 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";
export default function BillingPlans() {
const { data: plans } = useBillingPlans();
const [interval, setInterval] = useState("yearly");
if (!plans) {
return null;
}
const handleCheckout = async (priceId: string) => {
try {
const checkoutLink = await getCheckoutLink({
priceId: priceId,
});
window.location.href = checkoutLink.url;
} catch (err) {
console.error("Failed to get checkout link", err);
}
};
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;
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"
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

@ -0,0 +1,32 @@
import { Alert } from "@mantine/core";
import { useBillingQuery } from "@/ee/billing/queries/billing-query.ts";
import useTrial from "@/ee/hooks/use-trial.tsx";
export default function BillingTrial() {
const { data: billing, isLoading } = useBillingQuery();
const { trialDaysLeft } = useTrial();
if (isLoading) {
return null;
}
return (
<>
{trialDaysLeft > 0 && !billing && (
<Alert title="Your Trial is Active 🎉" color="blue" radius="md">
You have {trialDaysLeft} {trialDaysLeft === 1 ? "day" : "days"} left
in your 14-day trial. Please subscribe to a plan before your trial
ends.
</Alert>
)}
{trialDaysLeft === 0 ||
(trialDaysLeft === null && !billing && (
<Alert title="Your Trial has ended" color="red" radius="md">
Your 14-day trial has come to an end. Please subscribe to a plan to
continue using this service.
</Alert>
))}
</>
);
}

View File

@ -0,0 +1,10 @@
.root {
padding-top: var(--mantine-spacing-xs);
padding-bottom: var(--mantine-spacing-xs);
}
.label {
font-family:
Greycliff CF,
var(--mantine-font-family);
}

View File

@ -0,0 +1,34 @@
import { Button, Group, Text } from "@mantine/core";
import React from "react";
import { getBillingPortalLink } from "@/ee/billing/services/billing-service.ts";
export default function ManageBilling() {
const handleBillingPortal = async () => {
try {
const portalLink = await getBillingPortalLink();
window.location.href = portalLink.url;
} catch (err) {
console.error("Failed to get billing portal link", err);
}
};
return (
<>
<Group justify="space-between" wrap="wrap" gap="xl">
<div style={{ flex: 1, minWidth: "200px" }}>
<Text size="md" fw={500}>
Manage subscription
</Text>
<Text size="sm" c="dimmed">
Manage your your subscription, invoices, update payment details, and
more.
</Text>
</div>
<Button style={{ flexShrink: 0 }} onClick={handleBillingPortal}>
Manage
</Button>
</Group>
</>
);
}

View File

@ -0,0 +1,41 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import BillingPlans from "@/ee/billing/components/billing-plans.tsx";
import BillingTrial from "@/ee/billing/components/billing-trial.tsx";
import ManageBilling from "@/ee/billing/components/manage-billing.tsx";
import { Divider } from "@mantine/core";
import React from "react";
import BillingDetails from "@/ee/billing/components/billing-details.tsx";
import { useBillingQuery } from "@/ee/billing/queries/billing-query.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
export default function Billing() {
const { data: billing, isError: isBillingError } = useBillingQuery();
const { isAdmin } = useUserRole();
if (!isAdmin) {
return null;
}
return (
<>
<Helmet>
<title>Billing - {getAppName()}</title>
</Helmet>
<SettingsTitle title="Billing" />
<BillingTrial />
<BillingDetails />
{isBillingError && <BillingPlans />}
{billing && (
<>
<Divider my="lg" />
<ManageBilling />
</>
)}
</>
);
}

View File

@ -0,0 +1,20 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import {
getBilling,
getBillingPlans,
} from "@/ee/billing/services/billing-service.ts";
import { IBilling, IBillingPlan } from "@/ee/billing/types/billing.types.ts";
export function useBillingQuery(): UseQueryResult<IBilling, Error> {
return useQuery({
queryKey: ["billing"],
queryFn: () => getBilling(),
});
}
export function useBillingPlans(): UseQueryResult<IBillingPlan[], Error> {
return useQuery({
queryKey: ["billing-plans"],
queryFn: () => getBillingPlans(),
});
}

View File

@ -0,0 +1,29 @@
import api from "@/lib/api-client.ts";
import {
IBilling,
IBillingPlan,
IBillingPortal,
ICheckoutLink,
} from "@/ee/billing/types/billing.types.ts";
export async function getBilling(): Promise<IBilling> {
const req = await api.post<IBilling>("/billing/info");
return req.data;
}
export async function getBillingPlans(): Promise<IBillingPlan[]> {
const req = await api.post<IBillingPlan[]>("/billing/plans");
return req.data;
}
export async function getCheckoutLink(data: {
priceId: string;
}): Promise<ICheckoutLink> {
const req = await api.post<ICheckoutLink>("/billing/checkout", data);
return req.data;
}
export async function getBillingPortalLink(): Promise<IBillingPortal> {
const req = await api.post<IBillingPortal>("/billing/portal");
return req.data;
}

View File

@ -0,0 +1,49 @@
export enum BillingPlan {
STANDARD = "standard",
}
export interface IBilling {
id: string;
stripeSubscriptionId: string;
stripeCustomerId: string;
status: string;
quantity: number;
amount: number;
interval: string;
currency: string;
metadata: Record<string, any>;
stripePriceId: string;
stripeItemId: string;
stripeProductId: string;
periodStartAt: Date;
periodEndAt: Date;
cancelAtPeriodEnd: boolean;
cancelAt: Date;
canceledAt: Date;
workspaceId: string;
createdAt: Date;
updatedAt: Date;
deletedAt: Date;
}
export interface ICheckoutLink {
url: string;
}
export interface IBillingPortal {
url: string;
}
export interface IBillingPlan {
name: string;
description: string;
productId: string;
monthlyId: string;
yearlyId: string;
currency: string;
price: {
monthly: string;
yearly: string;
};
features: string[];
}

View File

@ -0,0 +1,17 @@
import { differenceInCalendarDays } from "date-fns";
export function formatInterval(interval: string): string {
if (interval === "month") {
return "monthly";
}
if (interval === "year") {
return "yearly";
}
}
export function getTrialDaysLeft(trialEndAt: Date) {
if (!trialEndAt) return null;
const daysLeft = differenceInCalendarDays(trialEndAt, new Date());
return daysLeft > 0 ? daysLeft : 0;
}

View File

@ -0,0 +1,13 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
import { getJoinedWorkspaces } from "@/ee/cloud/service/cloud-service.ts";
export function useJoinedWorkspacesQuery(): UseQueryResult<
Partial<IWorkspace[]>,
Error
> {
return useQuery({
queryKey: ["joined-workspaces"],
queryFn: () => getJoinedWorkspaces(),
});
}

View File

@ -0,0 +1,7 @@
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
import api from "@/lib/api-client.ts";
export async function getJoinedWorkspaces(): Promise<Partial<IWorkspace[]>> {
const req = await api.post<Partial<IWorkspace[]>>("/workspace/joined");
return req.data;
}

View File

@ -0,0 +1,96 @@
import * as z from "zod";
import { useForm, zodResolver } from "@mantine/form";
import {
Container,
Title,
TextInput,
Button,
Box,
Text,
Anchor,
Divider,
} from "@mantine/core";
import classes from "../../features/auth/components/auth.module.css";
import { getCheckHostname } from "@/features/workspace/services/workspace-service.ts";
import { useState } from "react";
import { getSubdomainHost } from "@/lib/config.ts";
import { Link } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route.ts";
import { useTranslation } from "react-i18next";
import JoinedWorkspaces from "@/ee/components/joined-workspaces.tsx";
import { useJoinedWorkspacesQuery } from "@/ee/cloud/query/cloud-query.ts";
const formSchema = z.object({
hostname: z.string().min(1, { message: "subdomain is required" }),
});
export function CloudLoginForm() {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState<boolean>(false);
const { data: joinedWorkspaces } = useJoinedWorkspacesQuery();
const form = useForm<any>({
validate: zodResolver(formSchema),
initialValues: {
hostname: "",
},
});
async function onSubmit(data: { hostname: string }) {
setIsLoading(true);
try {
const checkHostname = await getCheckHostname(data.hostname);
window.location.href = checkHostname.hostname;
} catch (err) {
if (err?.status === 404) {
form.setFieldError("hostname", "We could not find this workspace");
} else {
form.setFieldError("hostname", "An error occurred");
}
}
setIsLoading(false);
}
return (
<div>
<Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md">
{t("Login")}
</Title>
<JoinedWorkspaces />
{joinedWorkspaces?.length > 0 && (
<Divider my="xs" label="OR" labelPosition="center" />
)}
<form onSubmit={form.onSubmit(onSubmit)}>
<TextInput
type="text"
placeholder="my-team"
description="Enter your workspace hostname"
label="Workspace hostname"
rightSection={<Text fw={500}>.{getSubdomainHost()}</Text>}
rightSectionWidth={150}
withErrorStyles={false}
{...form.getInputProps("hostname")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
{t("Continue")}
</Button>
</form>
</Box>
</Container>
<Text ta="center">
{t("Don't have a workspace?")}{" "}
<Anchor component={Link} to={APP_ROUTE.AUTH.CREATE_WORKSPACE} fw={500}>
{t("Create new workspace")}
</Anchor>
</Text>
</div>
);
}

View File

@ -0,0 +1,13 @@
.workspace {
display: block;
width: 100%;
padding: var(--mantine-spacing-xs);
margin-bottom: var(--mantine-spacing-xs);
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
border-radius: var(--mantine-spacing-xs);
@mixin hover {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
}
}

View File

@ -0,0 +1,49 @@
import { Group, Text, UnstyledButton } from "@mantine/core";
import { useJoinedWorkspacesQuery } from "../cloud/query/cloud-query";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import classes from "./joined-workspaces.module.css";
import { IconChevronRight } from "@tabler/icons-react";
import { getHostnameUrl } from "@/ee/utils.ts";
import { Link } from "react-router-dom";
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
export default function JoinedWorkspaces() {
const { data, isLoading } = useJoinedWorkspacesQuery();
if (isLoading || !data || data?.length === 0) {
return null;
}
return (
<>
{data.map((workspace: Partial<IWorkspace>, index) => (
<UnstyledButton
key={index}
component={Link}
to={getHostnameUrl(workspace?.hostname) + "/home"}
className={classes.workspace}
>
<Group wrap="nowrap">
<CustomAvatar
avatarUrl={workspace?.logo}
name={workspace?.name}
variant="filled"
size="md"
/>
<div style={{ flex: 1 }}>
<Text size="sm" fw={500} lineClamp={1}>
{workspace?.name}
</Text>
<Text c="dimmed" size="sm">
{getHostnameUrl(workspace?.hostname)?.split("//")[1]}
</Text>
</div>
<IconChevronRight size={16} />
</Group>
</UnstyledButton>
))}
</>
);
}

View File

@ -0,0 +1,119 @@
import { Button, Group, Text, Modal, TextInput } from "@mantine/core";
import * as z from "zod";
import { useState } 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 { getSubdomainHost } from "@/lib/config.ts";
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { getHostnameUrl } from "@/ee/utils.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";
export default function ManageHostname() {
const { t } = useTranslation();
const [opened, { open, close }] = useDisclosure(false);
const [workspace] = useAtom(workspaceAtom);
const { isAdmin } = useUserRole();
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Hostname")}</Text>
<Text size="sm" c="dimmed" fw={500}>
{workspace?.hostname}.{getSubdomainHost()}
</Text>
</div>
{isAdmin && (
<Button onClick={open} variant="default">
{t("Change hostname")}
</Button>
)}
<Modal
opened={opened}
onClose={close}
title={t("Change hostname")}
centered
>
<ChangeHostnameForm onClose={close} />
</Modal>
</Group>
);
}
const formSchema = z.object({
hostname: z.string().min(4),
});
type FormValues = z.infer<typeof formSchema>;
interface ChangeHostnameFormProps {
onClose?: () => void;
}
function ChangeHostnameForm({ onClose }: ChangeHostnameFormProps) {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [currentUser, setCurrentUser] = useAtom(currentUserAtom);
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
initialValues: {
hostname: currentUser?.workspace?.hostname,
},
});
async function handleSubmit(data: Partial<IWorkspace>) {
setIsLoading(true);
if (data.hostname === currentUser?.workspace?.hostname) {
onClose();
return;
}
try {
await updateWorkspace({
hostname: data.hostname,
});
setCurrentUser(RESET);
window.location.href = getHostnameUrl(data.hostname.toLowerCase());
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
setIsLoading(false);
}
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<TextInput
type="text"
placeholder="e.g my-team"
label="Hostname"
variant="filled"
rightSection={<Text fw={500}>.{getSubdomainHost()}</Text>}
rightSectionWidth={150}
withErrorStyles={false}
width={200}
{...form.getInputProps("hostname")}
/>
<Group justify="flex-end" mt="md">
<Button type="submit" disabled={isLoading} loading={isLoading}>
{t("Change hostname")}
</Button>
</Group>
</form>
);
}

View File

@ -0,0 +1,25 @@
import { Button, Divider, Stack } from "@mantine/core";
import { getGoogleSignupUrl } from "@/ee/security/sso.utils.ts";
import { GoogleIcon } from "@/components/icons/google-icon.tsx";
export default function SsoCloudSignup() {
const handleSsoLogin = () => {
window.location.href = getGoogleSignupUrl();
};
return (
<>
<Stack align="stretch" justify="center" gap="sm">
<Button
onClick={handleSsoLogin}
leftSection={<GoogleIcon size={16} />}
variant="default"
fullWidth
>
Signup with Google
</Button>
</Stack>
<Divider my="xs" label="OR" labelPosition="center" />
</>
);
}

View File

@ -0,0 +1,57 @@
import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts";
import { Button, Divider, Stack } from "@mantine/core";
import { IconLock } from "@tabler/icons-react";
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
import { buildSsoLoginUrl } from "@/ee/security/sso.utils.ts";
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
import { GoogleIcon } from "@/components/icons/google-icon.tsx";
import { isCloud } from "@/lib/config.ts";
export default function SsoLogin() {
const { data, isLoading } = useWorkspacePublicDataQuery();
if (!data?.authProviders || data?.authProviders?.length === 0) {
return null;
}
const handleSsoLogin = (provider: IAuthProvider) => {
window.location.href = buildSsoLoginUrl({
providerId: provider.id,
type: provider.type,
workspaceId: data.id,
});
};
return (
<>
{(isCloud() || data.hasLicenseKey) && (
<>
<Stack align="stretch" justify="center" gap="sm">
{data.authProviders.map((provider) => (
<div key={provider.id}>
<Button
onClick={() => handleSsoLogin(provider)}
leftSection={
provider.type === SSO_PROVIDER.GOOGLE ? (
<GoogleIcon size={16} />
) : (
<IconLock size={16} />
)
}
variant="default"
fullWidth
>
{provider.name}
</Button>
</div>
))}
</Stack>
{!data.enforceSso && (
<Divider my="xs" label="OR" labelPosition="center" />
)}
</>
)}
</>
);
}

View File

@ -0,0 +1,15 @@
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { BillingPlan } from "@/ee/billing/types/billing.types.ts";
export const usePlan = () => {
const [workspace] = useAtom(workspaceAtom);
const isStandard =
typeof workspace?.plan === "string" &&
workspace?.plan.toLowerCase() === BillingPlan.STANDARD.toLowerCase();
return { isStandard };
};
export default usePlan;

View File

@ -0,0 +1,20 @@
import { useEffect } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { getAppUrl, getServerAppUrl, isCloud } from "@/lib/config.ts";
import APP_ROUTE from "@/lib/app-route.ts";
export const useRedirectToCloudSelect = () => {
const navigate = useNavigate();
const pathname = useLocation().pathname;
useEffect(() => {
const pathsToRedirect = ["/login", "/home"];
if (isCloud() && pathsToRedirect.includes(pathname)) {
const frontendUrl = getAppUrl();
const serverUrl = getServerAppUrl();
if (frontendUrl === serverUrl) {
navigate(APP_ROUTE.AUTH.SELECT_WORKSPACE);
}
}
}, [navigate]);
};

View File

@ -0,0 +1,36 @@
import { useEffect } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { isCloud } from "@/lib/config.ts";
import APP_ROUTE from "@/lib/app-route.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { notifications } from "@mantine/notifications";
import useTrial from "@/ee/hooks/use-trial.tsx";
export const useTrialEndAction = () => {
const navigate = useNavigate();
const pathname = useLocation().pathname;
const { isAdmin } = useUserRole();
const { trialDaysLeft } = useTrial();
useEffect(() => {
if (isCloud() && trialDaysLeft === 0) {
if (!pathname.startsWith("/settings")) {
notifications.show({
position: "top-right",
color: "red",
title: "Your 14-day trial has ended",
message:
"Please upgrade to a paid plan or contact your workspace admin.",
autoClose: false,
});
// only admins can access the billing page
if (isAdmin) {
navigate(APP_ROUTE.SETTINGS.WORKSPACE.BILLING);
} else {
navigate(APP_ROUTE.SETTINGS.ACCOUNT.PROFILE);
}
}
}
}, [navigate]);
};

View File

@ -0,0 +1,16 @@
import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { getTrialDaysLeft } from "@/ee/billing/utils.ts";
import { ICurrentUser } from "@/features/user/types/user.types.ts";
export const useTrial = () => {
const [currentUser] = useAtom<ICurrentUser>(currentUserAtom);
const workspace = currentUser?.workspace;
const trialDaysLeft = getTrialDaysLeft(workspace?.trialEndAt);
const isTrial = !!workspace?.trialEndAt && trialDaysLeft !== null;
return { isTrial: isTrial, trialDaysLeft: trialDaysLeft };
};
export default useTrial;

View File

@ -0,0 +1,89 @@
import * as z from "zod";
import React from "react";
import { Button, Group, Modal, Textarea } from "@mantine/core";
import { useForm, zodResolver } from "@mantine/form";
import { useTranslation } from "react-i18next";
import { useActivateMutation } from "@/ee/licence/queries/license-query.ts";
import { useDisclosure } from "@mantine/hooks";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import RemoveLicense from "@/ee/licence/components/remove-license.tsx";
export default function ActivateLicense() {
const { t } = useTranslation();
const [opened, { open, close }] = useDisclosure(false);
const [workspace] = useAtom(workspaceAtom);
return (
<Group justify="flex-end" wrap="nowrap" mb="sm">
<Button onClick={open}>
{workspace?.hasLicenseKey ? t("Update license") : t("Add license")}
</Button>
{workspace?.hasLicenseKey && <RemoveLicense />}
<Modal
size="550"
opened={opened}
onClose={close}
title={t("Enterprise license")}
centered
>
<ActivateLicenseForm onClose={close} />
</Modal>
</Group>
);
}
const formSchema = z.object({
licenseKey: z.string().min(1),
});
type FormValues = z.infer<typeof formSchema>;
interface ActivateLicenseFormProps {
onClose?: () => void;
}
export function ActivateLicenseForm({ onClose }: ActivateLicenseFormProps) {
const { t } = useTranslation();
const activateLicenseMutation = useActivateMutation();
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
initialValues: {
licenseKey: "",
},
});
async function handleSubmit(data: { licenseKey: string }) {
await activateLicenseMutation.mutateAsync(data.licenseKey);
form.reset();
onClose();
}
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Textarea
label={t("License key")}
description="Enter a valid enterprise license key. Contact sales@docmost.com to purchase one."
placeholder={t("e.g eyJhb.....")}
variant="filled"
autosize
minRows={3}
maxRows={5}
data-autofocus
{...form.getInputProps("licenseKey")}
/>
<Group justify="flex-end" mt="md">
<Button
type="submit"
disabled={activateLicenseMutation.isPending}
loading={activateLicenseMutation.isPending}
>
{t("Save")}
</Button>
</Group>
</form>
);
}

View File

@ -0,0 +1,71 @@
import React from "react";
import useUserRole from "@/hooks/use-user-role.tsx";
import classes from "@/ee/billing/components/billing.module.css";
import {
Group,
Paper,
SimpleGrid,
Text,
TextInput,
} from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import CopyTextButton from "@/components/common/copy.tsx";
export default function InstallationDetails() {
const { isAdmin } = useUserRole();
const [workspace] = useAtom(workspaceAtom);
if (!isAdmin) {
return null;
}
return (
<>
<SimpleGrid cols={{ base: 1, xs: 2, sm: 2 }}>
<Paper p="sm" radius="md" withBorder={true}>
<Group justify="apart" grow>
<div>
<Text
c="dimmed"
tt="uppercase"
fw={700}
fz="xs"
className={classes.label}
>
Workspace ID
</Text>
<TextInput
style={{ fontWeight: 700 }}
variant="unstyled"
readOnly
value={workspace?.id}
pointer
rightSection={<CopyTextButton text={workspace?.id} />}
/>
</div>
</Group>
</Paper>
<Paper p="md" radius="md" withBorder={true}>
<Group justify="apart">
<div>
<Text
c="dimmed"
tt="uppercase"
fw={700}
fz="xs"
className={classes.label}
>
Member count
</Text>
<Text fw={700} fz="lg" tt="capitalize">
{workspace?.memberCount}
</Text>
</div>
</Group>
</Paper>
</SimpleGrid>
</>
);
}

View File

@ -0,0 +1,81 @@
import { Badge, Table } from "@mantine/core";
import { format } from "date-fns";
import { useLicenseInfo } from "@/ee/licence/queries/license-query.ts";
import { isLicenseExpired } from "@/ee/licence/license.utils.ts";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
export default function LicenseDetails() {
const { data: license, isError } = useLicenseInfo();
const [workspace] = useAtom(workspaceAtom);
if (!license) {
return null;
}
if (isError) {
return null;
}
return (
<Table.ScrollContainer minWidth={500} py="md">
<Table
variant="vertical"
verticalSpacing="sm"
layout="fixed"
withTableBorder
>
<Table.Caption>
Contact sales@docmost.com for support and enquiries.
</Table.Caption>
<Table.Tbody>
<Table.Tr>
<Table.Th w={160}>Edition</Table.Th>
<Table.Td>
Enterprise {license.trial && <Badge color="green">Trial</Badge>}
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Th>Licensed to</Table.Th>
<Table.Td>{license.customerName}</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Th>Seat count</Table.Th>
<Table.Td>
{license.seatCount} ({workspace?.memberCount} used)
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Th>Issued at</Table.Th>
<Table.Td>{format(license.issuedAt, "dd MMMM, yyyy")}</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Th>Expires at</Table.Th>
<Table.Td>{format(license.expiresAt, "dd MMMM, yyyy")}</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Th>License ID</Table.Th>
<Table.Td>{license.id}</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Th>Status</Table.Th>
<Table.Td>
{isLicenseExpired(license) ? (
<Badge color="red" variant="light">
Expired
</Badge>
) : (
<Badge color="blue" variant="light">
Valid
</Badge>
)}
</Table.Td>
</Table.Tr>
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}

View File

@ -0,0 +1,3 @@
export default function LicenseMessage() {
return <>To unlock enterprise features, please contact sales@docmost.com to purchase a license.</>;
}

View File

@ -0,0 +1,39 @@
import { Group, Table, ThemeIcon } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
export default function OssDetails() {
return (
<Table.ScrollContainer minWidth={500} py="md">
<Table
variant="vertical"
verticalSpacing="sm"
layout="fixed"
withTableBorder
>
<Table.Caption>
To unlock enterprise features like SSO, contact sales@docmost.com.
</Table.Caption>
<Table.Tbody>
<Table.Tr>
<Table.Th w={160}>Edition</Table.Th>
<Table.Td>
<Group wrap="nowrap">
Open Source
<div>
<ThemeIcon
color="green"
variant="light"
size={24}
radius="xl"
>
<IconCheck size={16} />
</ThemeIcon>
</div>
</Group>
</Table.Td>
</Table.Tr>
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}

View File

@ -0,0 +1,33 @@
import { useTranslation } from "react-i18next";
import { useRemoveLicenseMutation } from "@/ee/licence/queries/license-query.ts";
import { Button, Group, Text } from "@mantine/core";
import { modals } from "@mantine/modals";
import React from "react";
export default function RemoveLicense() {
const { t } = useTranslation();
const removeLicenseMutation = useRemoveLicenseMutation();
const openDeleteModal = () =>
modals.openConfirmModal({
title: t("Remove license key"),
centered: true,
children: (
<Text size="sm">
{t(
"Are you sure you want to remove your license key? Your workspace will be downgraded to the non-enterprise version.",
)}
</Text>
),
labels: { confirm: t("Remove"), cancel: t("Don't") },
confirmProps: { color: "red" },
onConfirm: () => removeLicenseMutation.mutate(),
});
return (
<Group>
<Button variant="light" color="red" onClick={openDeleteModal}>Remove license</Button>
</Group>
);
}

View File

@ -0,0 +1,26 @@
import { ILicenseInfo } from "@/ee/licence/types/license.types.ts";
import { differenceInDays, isAfter } from "date-fns";
export const GRACE_PERIOD_DAYS = 10;
export function isLicenseExpired(license: ILicenseInfo): boolean {
return isAfter(new Date(), license.expiresAt);
}
export function daysToExpire(license: ILicenseInfo): number {
const days = differenceInDays(license.expiresAt, new Date());
return days > 0 ? days : 0;
}
export function isTrial(license: ILicenseInfo): boolean {
return license.trial;
}
export function isValid(license: ILicenseInfo): boolean {
return !isLicenseExpired(license);
}
export function hasExpiredGracePeriod(license: ILicenseInfo): boolean {
if (!isLicenseExpired(license)) return false;
return differenceInDays(new Date(), license.expiresAt) > GRACE_PERIOD_DAYS;
}

View File

@ -0,0 +1,35 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import React from "react";
import useUserRole from "@/hooks/use-user-role.tsx";
import LicenseDetails from "@/ee/licence/components/license-details.tsx";
import ActivateLicenseForm from "@/ee/licence/components/activate-license-modal.tsx";
import InstallationDetails from "@/ee/licence/components/installation-details.tsx";
import OssDetails from "@/ee/licence/components/oss-details.tsx";
import { useAtom } from "jotai/index";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
export default function License() {
const [workspace] = useAtom(workspaceAtom);
const { isAdmin } = useUserRole();
if (!isAdmin) {
return null;
}
return (
<>
<Helmet>
<title>License - {getAppName()}</title>
</Helmet>
<SettingsTitle title="License" />
<ActivateLicenseForm />
<InstallationDetails />
{workspace?.hasLicenseKey ? <LicenseDetails /> : <OssDetails />}
</>
);
}

View File

@ -0,0 +1,52 @@
import {
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
activateLicense,
removeLicense,
getLicenseInfo,
} from "@/ee/licence/services/license-service.ts";
import { ILicenseInfo } from "@/ee/licence/types/license.types.ts";
import { notifications } from "@mantine/notifications";
export function useLicenseInfo(): UseQueryResult<ILicenseInfo, Error> {
return useQuery({
queryKey: ["license"],
queryFn: () => getLicenseInfo(),
staleTime: 5 * 60 * 1000,
});
}
export function useActivateMutation() {
const queryClient = useQueryClient();
return useMutation<ILicenseInfo, Error, string>({
mutationFn: (licenseKey) => activateLicense(licenseKey),
onSuccess: () => {
notifications.show({ message: "License activated successfully" });
queryClient.refetchQueries({
queryKey: ["license"],
});
queryClient.refetchQueries({ queryKey: ["currentUser"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useRemoveLicenseMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => removeLicense(),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["license"] });
queryClient.refetchQueries({ queryKey: ["currentUser"] });
},
});
}

View File

@ -0,0 +1,18 @@
import api from "@/lib/api-client.ts";
import { ILicenseInfo } from "@/ee/licence/types/license.types.ts";
export async function getLicenseInfo(): Promise<ILicenseInfo> {
const req = await api.post<ILicenseInfo>("/license/info");
return req.data;
}
export async function activateLicense(
licenseKey: string,
): Promise<ILicenseInfo> {
const req = await api.post<ILicenseInfo>("/license/activate", { licenseKey });
return req.data;
}
export async function removeLicense(): Promise<void> {
await api.post<void>("/license/remove");
}

View File

@ -0,0 +1,8 @@
export interface ILicenseInfo {
id: string;
customerName: string;
seatCount: number;
issuedAt: Date;
expiresAt: Date;
trial: boolean;
}

View File

@ -0,0 +1,20 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import { CloudLoginForm } from "@/ee/components/cloud-login-form.tsx";
import { useTranslation } from "react-i18next";
export default function CloudLogin() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>
{t("Login")} - {getAppName()}
</title>
</Helmet>
<CloudLoginForm />
</>
);
}

View File

@ -0,0 +1,15 @@
import { SetupWorkspaceForm } from "@/features/auth/components/setup-workspace-form.tsx";
import { Helmet } from "react-helmet-async";
import React from "react";
import { getAppName } from "@/lib/config.ts";
export default function CreateWorkspace() {
return (
<>
<Helmet>
<title>Create Workspace - {getAppName()}</title>
</Helmet>
<SetupWorkspaceForm />
</>
);
}

View File

@ -0,0 +1,88 @@
import { useAtom } from "jotai";
import * as z from "zod";
import { useForm, zodResolver } from "@mantine/form";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { Button, Text, TagsInput } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
const formSchema = z.object({
emailDomains: z.array(z.string()),
});
type FormValues = z.infer<typeof formSchema>;
export default function AllowedDomains() {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [, setDomains] = useState<string[]>([]);
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
initialValues: {
emailDomains: workspace?.emailDomains || [],
},
});
async function handleSubmit(data: Partial<IWorkspace>) {
setIsLoading(true);
try {
const updatedWorkspace = await updateWorkspace({
emailDomains: data.emailDomains,
});
setWorkspace(updatedWorkspace);
notifications.show({
message: t("Updated successfully"),
});
} catch (err) {
console.log(err);
notifications.show({
message: err.response.data.message,
color: "red",
});
}
form.resetDirty();
setIsLoading(false);
}
return (
<>
<div>
<Text size="md">Allowed email domains</Text>
<Text size="sm" c="dimmed">
Only users with email addresses from these domains can signup via SSO.
</Text>
</div>
<form onSubmit={form.onSubmit(handleSubmit)}>
<TagsInput
mt="sm"
description={t(
"Enter valid domain names separated by comma or space",
)}
placeholder={t("e.g acme.com")}
variant="filled"
splitChars={[",", " "]}
maxDropdownHeight={0}
maxTags={20}
onChange={setDomains}
{...form.getInputProps("emailDomains")}
/>
<Button
type="submit"
mt="sm"
disabled={!form.isDirty()}
loading={isLoading}
>
{t("Save")}
</Button>
</form>
</>
);
}

View File

@ -0,0 +1,79 @@
import React, { useState } from "react";
import { useDisclosure } from "@mantine/hooks";
import { Button, Menu, Group } from "@mantine/core";
import { IconChevronDown, IconLock } from "@tabler/icons-react";
import { useCreateSsoProviderMutation } from "@/ee/security/queries/security-query.ts";
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
import SsoProviderModal from "@/ee/security/components/sso-provider-modal.tsx";
import { OpenIdIcon } from "@/components/icons/openid-icon.tsx";
export default function CreateSsoProvider() {
const [opened, { open, close }] = useDisclosure(false);
const [provider, setProvider] = useState<IAuthProvider | null>(null);
const createSsoProviderMutation = useCreateSsoProviderMutation();
const handleCreateSAML = async () => {
try {
const newProvider = await createSsoProviderMutation.mutateAsync({
type: SSO_PROVIDER.SAML,
name: "SAML",
});
setProvider(newProvider);
open();
} catch (error) {
console.error("Failed to create SAML provider", error);
}
};
const handleCreateOIDC = async () => {
try {
const newProvider = await createSsoProviderMutation.mutateAsync({
type: SSO_PROVIDER.OIDC,
name: "OIDC",
});
setProvider(newProvider);
open();
} catch (error) {
console.error("Failed to create OIDC provider", error);
}
};
return (
<>
<SsoProviderModal opened={opened} onClose={close} provider={provider} />
<Group justify="flex-end">
<Menu
transitionProps={{ transition: "pop-top-right" }}
position="bottom"
width={220}
withinPortal
>
<Menu.Target>
<Button rightSection={<IconChevronDown size={16} />} pr={12}>
Create SSO
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={handleCreateSAML}
leftSection={<IconLock size={16} />}
>
SAML
</Menu.Item>
<Menu.Item
onClick={handleCreateOIDC}
leftSection={<OpenIdIcon size={16} />}
>
OpenID (OIDC)
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</>
);
}

View File

@ -0,0 +1,61 @@
import { Group, Text, Switch, MantineSize } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
export default function EnforceSso() {
const { t } = useTranslation();
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Enforce SSO")}</Text>
<Text size="sm" c="dimmed">
{t(
"Once enforced, members will not able able to login with email and password.",
)}
</Text>
</div>
<EnforceSsoToggle />
</Group>
);
}
interface EnforceSsoToggleProps {
size?: MantineSize;
label?: string;
}
export function EnforceSsoToggle({ size, label }: EnforceSsoToggleProps) {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.enforceSso);
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({ enforceSso: value });
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Switch
size={size}
label={label}
labelPosition="left"
defaultChecked={checked}
onChange={handleChange}
aria-label={t("Toggle sso enforcement")}
/>
);
}

View File

@ -0,0 +1,91 @@
import React from "react";
import { z } from "zod";
import { useForm, zodResolver } from "@mantine/form";
import { Box, Button, Group, Stack, Switch, TextInput } from "@mantine/core";
import classes from "@/ee/security/components/sso.module.css";
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
import { useTranslation } from "react-i18next";
import { useUpdateSsoProviderMutation } from "@/ee/security/queries/security-query.ts";
const ssoSchema = z.object({
name: z.string().min(1, "Provider name is required"),
isEnabled: z.boolean(),
allowSignup: z.boolean(),
});
type SSOFormValues = z.infer<typeof ssoSchema>;
interface SsoFormProps {
provider: IAuthProvider;
onClose?: () => void;
}
export function SsoGoogleForm({ provider, onClose }: SsoFormProps) {
const { t } = useTranslation();
const updateSsoProviderMutation = useUpdateSsoProviderMutation();
const form = useForm<SSOFormValues>({
initialValues: {
name: provider.name || "",
isEnabled: provider.isEnabled,
allowSignup: provider.allowSignup,
},
validate: zodResolver(ssoSchema),
});
const handleSubmit = async (values: SSOFormValues) => {
const ssoData: Partial<IAuthProvider> = {
providerId: provider.id,
};
if (form.isDirty("name")) {
ssoData.name = values.name;
}
if (form.isDirty("isEnabled")) {
ssoData.isEnabled = values.isEnabled;
}
if (form.isDirty("allowSignup")) {
ssoData.allowSignup = values.allowSignup;
}
await updateSsoProviderMutation.mutateAsync(ssoData);
form.resetDirty();
onClose();
};
return (
<Box maw={600} mx="auto">
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
label="Display name"
placeholder="e.g Okta SSO"
readOnly
{...form.getInputProps("name")}
/>
<Group justify="space-between">
<div>{t("Allow signup")}</div>
<Switch
className={classes.switch}
checked={form.values.allowSignup}
{...form.getInputProps("allowSignup")}
/>
</Group>
<Group justify="space-between">
<div>{t("Enabled")}</div>
<Switch
className={classes.switch}
checked={form.values.isEnabled}
{...form.getInputProps("isEnabled")}
/>
</Group>
<Group mt="md" justify="flex-end">
<Button type="submit" disabled={!form.isDirty()}>
{t("Save")}
</Button>
</Group>
</Stack>
</form>
</Box>
);
}

View File

@ -0,0 +1,140 @@
import React from "react";
import { z } from "zod";
import { useForm, zodResolver } from "@mantine/form";
import { Box, Button, Group, Stack, Switch, TextInput } from "@mantine/core";
import { buildCallbackUrl } from "@/ee/security/sso.utils.ts";
import classes from "@/ee/security/components/sso.module.css";
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
import CopyTextButton from "@/components/common/copy.tsx";
import { useTranslation } from "react-i18next";
import { useUpdateSsoProviderMutation } from "@/ee/security/queries/security-query.ts";
const ssoSchema = z.object({
name: z.string().min(1, "Display name is required"),
oidcIssuer: z.string().url(),
oidcClientId: z.string().min(1, "Client id is required"),
oidcClientSecret: z.string().min(1, "Client secret is required"),
isEnabled: z.boolean(),
allowSignup: z.boolean(),
});
type SSOFormValues = z.infer<typeof ssoSchema>;
interface SsoFormProps {
provider: IAuthProvider;
onClose?: () => void;
}
export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
const { t } = useTranslation();
const updateSsoProviderMutation = useUpdateSsoProviderMutation();
const form = useForm<SSOFormValues>({
initialValues: {
name: provider.name || "",
oidcIssuer: provider.oidcIssuer || "",
oidcClientId: provider.oidcClientId || "",
oidcClientSecret: provider.oidcClientSecret || "",
isEnabled: provider.isEnabled,
allowSignup: provider.allowSignup,
},
validate: zodResolver(ssoSchema),
});
const callbackUrl = buildCallbackUrl({
providerId: provider.id,
type: provider.type,
});
const handleSubmit = async (values: SSOFormValues) => {
const ssoData: Partial<IAuthProvider> = {
providerId: provider.id,
};
if (form.isDirty("name")) {
ssoData.name = values.name;
}
if (form.isDirty("oidcIssuer")) {
ssoData.oidcIssuer = values.oidcIssuer;
}
if (form.isDirty("oidcClientId")) {
ssoData.oidcClientId = values.oidcClientId;
}
if (form.isDirty("oidcClientSecret")) {
ssoData.oidcClientSecret = values.oidcClientSecret;
}
if (form.isDirty("isEnabled")) {
ssoData.isEnabled = values.isEnabled;
}
if (form.isDirty("allowSignup")) {
ssoData.allowSignup = values.allowSignup;
}
await updateSsoProviderMutation.mutateAsync(ssoData);
form.resetDirty();
onClose();
};
return (
<Box maw={600} mx="auto">
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
label="Display name"
placeholder="e.g Google SSO"
data-autofocus
{...form.getInputProps("name")}
/>
<TextInput
label="Callback URL"
variant="filled"
value={callbackUrl}
pointer
readOnly
rightSection={<CopyTextButton text={callbackUrl} />}
/>
<TextInput
label="Issuer URL"
description="Enter your OIDC issuer URL"
placeholder="e.g https://accounts.google.com/"
{...form.getInputProps("oidcIssuer")}
/>
<TextInput
label="Client ID"
description="Enter your OIDC ClientId"
placeholder="e.g 292085223830.apps.googleusercontent.com"
{...form.getInputProps("oidcClientId")}
/>
<TextInput
label="Client Secret"
description="Enter your OIDC Client Secret"
placeholder="e.g OCSPX-zVCkotEPGRnJA1XKUrbgjlf7PQQ-"
{...form.getInputProps("oidcClientSecret")}
/>
<Group justify="space-between">
<div>{t("Allow signup")}</div>
<Switch
className={classes.switch}
checked={form.values.allowSignup}
{...form.getInputProps("allowSignup")}
/>
</Group>
<Group justify="space-between">
<div>{t("Enabled")}</div>
<Switch
className={classes.switch}
checked={form.values.isEnabled}
{...form.getInputProps("isEnabled")}
/>
</Group>
<Group mt="md" justify="flex-end">
<Button type="submit" disabled={!form.isDirty()}>
{t("Save")}
</Button>
</Group>
</Stack>
</form>
</Box>
);
}

View File

@ -0,0 +1,186 @@
import React, { useState } from "react";
import {
useDeleteSsoProviderMutation,
useGetSsoProviders,
} from "@/ee/security/queries/security-query.ts";
import {
ActionIcon,
Badge,
Card,
Group,
Menu,
Table,
Text,
ThemeIcon,
} from "@mantine/core";
import {
IconCheck,
IconDots,
IconLock,
IconPencil,
IconTrash,
IconX,
} from "@tabler/icons-react";
import { useDisclosure } from "@mantine/hooks";
import { modals } from "@mantine/modals";
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
import { useTranslation } from "react-i18next";
import SsoProviderModal from "@/ee/security/components/sso-provider-modal.tsx";
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
import { GoogleIcon } from "@/components/icons/google-icon.tsx";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import RoleSelectMenu from "@/components/ui/role-select-menu.tsx";
import { getUserRoleLabel } from "@/features/workspace/types/user-role-data.ts";
export default function SsoProviderList() {
const { t } = useTranslation();
const { data, isLoading } = useGetSsoProviders();
const [opened, { open, close }] = useDisclosure(false);
const deleteSsoProviderMutation = useDeleteSsoProviderMutation();
const [editProvider, setEditProvider] = useState<IAuthProvider | null>(null);
if (isLoading || !data) {
return null;
}
if (data?.length === 0) {
return <Text c="dimmed">{t("No SSO providers found.")}</Text>;
}
const handleEdit = (provider: IAuthProvider) => {
setEditProvider(provider);
open();
};
const openDeleteModal = (providerId: string) =>
modals.openConfirmModal({
title: t("Delete SSO provider"),
centered: true,
children: (
<Text size="sm">
{t("Are you sure you want to delete this SSO provider?")}
</Text>
),
labels: { confirm: t("Delete"), cancel: t("Don't") },
confirmProps: { color: "red" },
onConfirm: () => deleteSsoProviderMutation.mutateAsync(providerId),
});
return (
<>
<Card shadow="sm" radius="sm">
<Table.ScrollContainer minWidth={500}>
<Table verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Name")}</Table.Th>
<Table.Th>{t("Type")}</Table.Th>
<Table.Th>{t("Status")}</Table.Th>
<Table.Th>{t("Allow signup")}</Table.Th>
<Table.Th>{t("Action")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data
.sort((a, b) => {
const enabledDiff = Number(b.isEnabled) - Number(a.isEnabled);
if (enabledDiff !== 0) return enabledDiff;
return a.name.localeCompare(b.name);
})
.map((provider: IAuthProvider, index) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="xs" wrap="nowrap">
{provider.type === SSO_PROVIDER.GOOGLE ? (
<GoogleIcon size={16} />
) : (
<IconLock size={16} />
)}
<div>
<Text fz="sm" fw={500}>
{provider.name}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<Badge color={"gray"} variant="light">
{provider.type.toUpperCase()}
</Badge>
</Table.Td>
<Table.Td>
<Badge
color={provider.isEnabled ? "blue" : "gray"}
variant="light"
>
{provider.isEnabled ? "Active" : "InActive"}
</Badge>
</Table.Td>
<Table.Td>
{provider.allowSignup ? (
<ThemeIcon variant="light" size={24} radius="xl">
<IconCheck size={16} />
</ThemeIcon>
) : (
<ThemeIcon
variant="light"
color="red"
size={24}
radius="xl"
>
<IconX size={16} />
</ThemeIcon>
)}
</Table.Td>
<Table.Td>
<ActionIcon
variant="subtle"
color="gray"
onClick={() => handleEdit(provider)}
>
<IconPencil size={16} />
</ActionIcon>
<Menu
transitionProps={{ transition: "pop" }}
withArrow
position="bottom-end"
withinPortal
>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={() => handleEdit(provider)}
leftSection={<IconPencil size={16} />}
>
{t("Edit")}
</Menu.Item>
<Menu.Item
onClick={() => openDeleteModal(provider.id)}
leftSection={<IconTrash size={16} />}
color="red"
disabled={provider.type === SSO_PROVIDER.GOOGLE}
>
{t("Delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</Card>
<SsoProviderModal
opened={opened}
onClose={close}
provider={editProvider}
/>
</>
);
}

View File

@ -0,0 +1,43 @@
import React from "react";
import { Modal } from "@mantine/core";
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
import { SsoSamlForm } from "@/ee/security/components/sso-saml-form.tsx";
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
import { SsoOIDCForm } from "@/ee/security/components/sso-oidc-form.tsx";
import { SsoGoogleForm } from "@/ee/security/components/sso-google-form.tsx";
interface SsoModalProps {
opened: boolean;
onClose: () => void;
provider: IAuthProvider | null;
}
export default function SsoProviderModal({
opened,
onClose,
provider,
}: SsoModalProps) {
if (!provider) {
return null;
}
return (
<Modal
opened={opened}
title={`${provider.type.toUpperCase()} Configuration`}
onClose={onClose}
>
{provider.type === SSO_PROVIDER.SAML && (
<SsoSamlForm provider={provider} onClose={onClose} />
)}
{provider.type === SSO_PROVIDER.OIDC && (
<SsoOIDCForm provider={provider} onClose={onClose} />
)}
{provider.type === SSO_PROVIDER.GOOGLE && (
<SsoGoogleForm provider={provider} onClose={onClose} />
)}
</Modal>
);
}

View File

@ -0,0 +1,153 @@
import React from "react";
import { z } from "zod";
import { useForm, zodResolver } from "@mantine/form";
import {
Box,
Button,
Group,
Stack,
Switch,
Textarea,
TextInput,
} from "@mantine/core";
import {
buildCallbackUrl,
buildSamlEntityId,
} from "@/ee/security/sso.utils.ts";
import classes from "@/ee/security/components/sso.module.css";
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
import CopyTextButton from "@/components/common/copy.tsx";
import { useTranslation } from "react-i18next";
import { useUpdateSsoProviderMutation } from "@/ee/security/queries/security-query.ts";
const ssoSchema = z.object({
name: z.string().min(1, "Display name is required"),
samlUrl: z.string().url(),
samlCertificate: z.string().min(1, "SAML Idp Certificate is required"),
isEnabled: z.boolean(),
allowSignup: z.boolean(),
});
type SSOFormValues = z.infer<typeof ssoSchema>;
interface SsoFormProps {
provider: IAuthProvider;
onClose?: () => void;
}
export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
const { t } = useTranslation();
const updateSsoProviderMutation = useUpdateSsoProviderMutation();
const form = useForm<SSOFormValues>({
initialValues: {
name: provider.name || "",
samlUrl: provider.samlUrl || "",
samlCertificate: provider.samlCertificate || "",
isEnabled: provider.isEnabled,
allowSignup: provider.allowSignup,
},
validate: zodResolver(ssoSchema),
});
const callbackUrl = buildCallbackUrl({
providerId: provider.id,
type: provider.type,
});
const samlEntityId = buildSamlEntityId(provider.id);
const handleSubmit = async (values: SSOFormValues) => {
const ssoData: Partial<IAuthProvider> = {
providerId: provider.id,
};
if (form.isDirty("name")) {
ssoData.name = values.name;
}
if (form.isDirty("samlUrl")) {
ssoData.samlUrl = values.samlUrl;
}
if (form.isDirty("samlCertificate")) {
ssoData.samlCertificate = values.samlCertificate;
}
if (form.isDirty("isEnabled")) {
ssoData.isEnabled = values.isEnabled;
}
if (form.isDirty("allowSignup")) {
ssoData.allowSignup = values.allowSignup;
}
await updateSsoProviderMutation.mutateAsync(ssoData);
form.resetDirty();
onClose();
};
return (
<Box maw={600} mx="auto">
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
label="Display name"
placeholder="e.g Azure Entra"
data-autofocus
{...form.getInputProps("name")}
/>
<TextInput
label="Entity ID"
variant="filled"
value={buildSamlEntityId(provider.id)}
rightSection={<CopyTextButton text={samlEntityId} />}
pointer
readOnly
/>
<TextInput
label="Callback URL (ACS)"
variant="filled"
value={callbackUrl}
pointer
readOnly
rightSection={<CopyTextButton text={callbackUrl} />}
/>
<TextInput
label="IDP Login URL"
description="Enter your IDP login URL"
placeholder="e.g https://login.microsoftonline.com/7d6246d1-273b-4981-ad1e-e7bb27b86569/saml2"
{...form.getInputProps("samlUrl")}
/>
<Textarea
label="IDP Certificate"
description="Enter your IDP certificate"
placeholder="-----BEGIN CERTIFICATE-----"
autosize
minRows={3}
maxRows={5}
{...form.getInputProps("samlCertificate")}
/>
<Group justify="space-between">
<div>{t("Allow signup")}</div>
<Switch
className={classes.switch}
checked={form.values.allowSignup}
{...form.getInputProps("allowSignup")}
/>
</Group>
<Group justify="space-between">
<div>{t("Enabled")}</div>
<Switch
className={classes.switch}
checked={form.values.isEnabled}
{...form.getInputProps("isEnabled")}
/>
</Group>
<Group mt="md" justify="flex-end">
<Button type="submit" disabled={!form.isDirty()}>
{t("Save")}
</Button>
</Group>
</Stack>
</form>
</Box>
);
}

View File

@ -0,0 +1,14 @@
.item {
& + & {
padding-top: var(--mantine-spacing-sm);
margin-top: var(--mantine-spacing-sm);
border-top: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
}
}
.switch {
& * {
cursor: pointer;
}
}

View File

@ -0,0 +1,5 @@
export enum SSO_PROVIDER {
OIDC = 'oidc',
SAML = 'saml',
GOOGLE = 'google',
}

View File

@ -0,0 +1,52 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import { Divider, Title } from "@mantine/core";
import React from "react";
import useUserRole from "@/hooks/use-user-role.tsx";
import SsoProviderList from "@/ee/security/components/sso-provider-list.tsx";
import CreateSsoProvider from "@/ee/security/components/create-sso-provider.tsx";
import EnforceSso from "@/ee/security/components/enforce-sso.tsx";
import AllowedDomains from "@/ee/security/components/allowed-domains.tsx";
import { useTranslation } from "react-i18next";
import usePlan from "@/ee/hooks/use-plan.tsx";
export default function Security() {
const { t } = useTranslation();
const { isAdmin } = useUserRole();
const { isStandard } = usePlan();
// if is not cloud or enterprise return null
//{(isCloud() || isEnterprise()) && (
if (!isAdmin) {
return null;
}
return (
<>
<Helmet>
<title>Security - {getAppName()}</title>
</Helmet>
<SettingsTitle title={t("Security")} />
<AllowedDomains />
<Divider my="lg" />
<Title order={4} my="lg">
Single sign-on (SSO)
</Title>
<EnforceSso />
<Divider my="lg" />
{!isStandard && <CreateSsoProvider />}
<Divider size={0} my="lg" />
<SsoProviderList />
</>
);
}

View File

@ -0,0 +1,88 @@
import {
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
createSsoProvider,
deleteSsoProvider,
getSsoProviderById,
getSsoProviders,
updateSsoProvider,
} from "@/ee/security/services/security-service.ts";
import { notifications } from "@mantine/notifications";
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
export function useGetSsoProviders(): UseQueryResult<IAuthProvider[], Error> {
return useQuery({
queryKey: ["sso-providers"],
queryFn: () => getSsoProviders(),
staleTime: 5 * 60 * 1000,
});
}
export function useSsoProvider(
providerId: string,
): UseQueryResult<IAuthProvider, Error> {
return useQuery({
queryKey: ["sso-provider", providerId],
queryFn: () => getSsoProviderById({ providerId }),
enabled: !!providerId,
staleTime: 5 * 60 * 1000,
});
}
export function useCreateSsoProviderMutation() {
const queryClient = useQueryClient();
return useMutation<any, Error, Partial<IAuthProvider>>({
mutationFn: (data: Partial<IAuthProvider>) => createSsoProvider(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["sso-providers"],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useUpdateSsoProviderMutation() {
const queryClient = useQueryClient();
return useMutation<any, Error, Partial<IAuthProvider>>({
mutationFn: (data: Partial<IAuthProvider>) => updateSsoProvider(data),
onSuccess: (data, variables) => {
notifications.show({ message: "Updated successfully" });
queryClient.invalidateQueries({
queryKey: ["sso-providers"],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useDeleteSsoProviderMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (providerId: string) => deleteSsoProvider({ providerId }),
onSuccess: (data, variables) => {
notifications.show({ message: "Deleted successfully" });
queryClient.invalidateQueries({
queryKey: ["sso-providers"],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}

View File

@ -0,0 +1,32 @@
import api from "@/lib/api-client.ts";
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
export async function getSsoProviderById(data: {
providerId: string;
}): Promise<any> {
const req = await api.post<IAuthProvider>("/sso/info");
return req.data;
}
export async function getSsoProviders(): Promise<IAuthProvider[]> {
const req = await api.post<IAuthProvider[]>("/sso/providers");
return req.data;
}
export async function createSsoProvider(data: any): Promise<IAuthProvider> {
const req = await api.post<IAuthProvider>("/sso/create", data);
return req.data;
}
export async function deleteSsoProvider(data: {
providerId: string;
}): Promise<void> {
await api.post<any>("/sso/delete", data);
}
export async function updateSsoProvider(
data: Partial<IAuthProvider>,
): Promise<IAuthProvider> {
const req = await api.post<IAuthProvider>("/sso/update", data);
return req.data;
}

View File

@ -0,0 +1,39 @@
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
import { getAppUrl, getServerAppUrl } from "@/lib/config.ts";
export function buildCallbackUrl(opts: {
providerId: string;
type: SSO_PROVIDER;
}): string {
const { providerId, type } = opts;
const domain = getAppUrl();
if (type === SSO_PROVIDER.GOOGLE) {
return `${domain}/api/sso/${type}/callback`;
}
return `${domain}/api/sso/${type}/${providerId}/callback`;
}
export function buildSsoLoginUrl(opts: {
providerId: string;
type: SSO_PROVIDER;
workspaceId?: string;
}): string {
const { providerId, type, workspaceId } = opts;
const domain = getAppUrl();
if (type === SSO_PROVIDER.GOOGLE) {
return `${getServerAppUrl()}/api/sso/${type}/login?workspaceId=${workspaceId}`;
}
return `${domain}/api/sso/${type}/${providerId}/login`;
}
export function getGoogleSignupUrl(): string {
// Google login is instance-wide. Use the env APP_URL instead
return `${getServerAppUrl()}/api/sso/${SSO_PROVIDER.GOOGLE}/signup`;
}
export function buildSamlEntityId(providerId: string): string {
const domain = getAppUrl();
return `${domain}/api/sso/${SSO_PROVIDER.SAML}/${providerId}/login`;
}

View File

@ -0,0 +1,20 @@
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
export interface IAuthProvider {
id: string;
name: string;
type: SSO_PROVIDER;
samlUrl: string;
samlCertificate: string;
oidcIssuer: string;
oidcClientId: string;
oidcClientSecret: string;
allowSignup: boolean;
isEnabled: boolean;
creatorId: string;
workspaceId: string;
createdAt: Date;
updatedAt: Date;
deletedAt: Date;
providerId: string;
}

View File

@ -0,0 +1,16 @@
import { getServerAppUrl, getSubdomainHost } from "@/lib/config.ts";
export function getHostnameUrl(hostname: string): string {
const url = new URL(getServerAppUrl());
const isHttps = url.protocol === "https:";
const protocol = isHttps ? "https" : "http";
return `${protocol}://${hostname}.${getSubdomainHost()}`;
}
export function exchangeTokenRedirectUrl(
hostname: string,
exchangeToken: string,
) {
return getHostnameUrl(hostname) + "/api/auth/exchange?token=" + exchangeToken;
}

View File

@ -2,4 +2,17 @@
box-shadow: rgba(0, 0, 0, 0.07) 0px 2px 45px 4px;
border-radius: 4px;
background: light-dark(var(--mantine-color-body), rgba(0, 0, 0, 0.1));
margin-top: 150px;
margin-bottom: 20px;
@media (max-width: $mantine-breakpoint-sm) {
margin-top: 50px;
margin-bottom: 20px;
}
}
.containerBox {
margin-top: 40px;
}

View File

@ -35,8 +35,8 @@ export function ForgotPasswordForm() {
}
return (
<Container size={420} my={40} className={classes.container}>
<Box p="xl" mt={200}>
<Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md">
{t("Forgot password")}
</Title>

View File

@ -65,8 +65,8 @@ export function InviteSignUpForm() {
}
return (
<Container size={420} my={40} className={classes.container}>
<Box p="xl" mt={200}>
<Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md">
{t("Join the workspace")}
</Title>

View File

@ -10,12 +10,17 @@ import {
PasswordInput,
Box,
Anchor,
Group,
} from "@mantine/core";
import classes from "./auth.module.css";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
import { Link, useNavigate } from "react-router-dom";
import { Link } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route.ts";
import { useTranslation } from "react-i18next";
import SsoLogin from "@/ee/components/sso-login.tsx";
import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts";
import { Error404 } from "@/components/ui/error-404.tsx";
import React from "react";
const formSchema = z.object({
email: z
@ -29,6 +34,12 @@ export function LoginForm() {
const { t } = useTranslation();
const { signIn, isLoading } = useAuth();
useRedirectIfAuthenticated();
const {
data,
isLoading: isDataLoading,
isError,
error,
} = useWorkspacePublicDataQuery();
const form = useForm<ILogin>({
validate: zodResolver(formSchema),
@ -42,44 +53,60 @@ export function LoginForm() {
await signIn(data);
}
if (isDataLoading) {
return null;
}
if (isError && error?.["response"]?.status === 404) {
return <Error404 />;
}
return (
<Container size={420} my={40} className={classes.container}>
<Box p="xl" mt={200}>
<Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md">
{t("Login")}
</Title>
<form onSubmit={form.onSubmit(onSubmit)}>
<TextInput
id="email"
type="email"
label={t("Email")}
placeholder="email@example.com"
variant="filled"
{...form.getInputProps("email")}
/>
<SsoLogin />
<PasswordInput
label={t("Password")}
placeholder={t("Your password")}
variant="filled"
mt="md"
{...form.getInputProps("password")}
/>
{!data?.enforceSso && (
<>
<form onSubmit={form.onSubmit(onSubmit)}>
<TextInput
id="email"
type="email"
label={t("Email")}
placeholder="email@example.com"
variant="filled"
{...form.getInputProps("email")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
{t("Sign In")}
</Button>
</form>
<PasswordInput
label={t("Password")}
placeholder={t("Your password")}
variant="filled"
mt="md"
{...form.getInputProps("password")}
/>
<Anchor
to={APP_ROUTE.AUTH.FORGOT_PASSWORD}
component={Link}
underline="never"
size="sm"
>
{t("Forgot your password?")}
</Anchor>
<Group justify="flex-end" mt="sm">
<Anchor
to={APP_ROUTE.AUTH.FORGOT_PASSWORD}
component={Link}
underline="never"
size="sm"
>
{t("Forgot your password?")}
</Anchor>
</Group>
<Button type="submit" fullWidth mt="md" loading={isLoading}>
{t("Sign In")}
</Button>
</form>
</>
)}
</Box>
</Container>
);

View File

@ -37,8 +37,8 @@ export function PasswordResetForm({ resetToken }: PasswordResetFormProps) {
}
return (
<Container size={420} my={40} className={classes.container}>
<Box p="xl" mt={200}>
<Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md">
{t("Password reset")}
</Title>

View File

@ -1,6 +1,5 @@
import * as React from "react";
import * as z from "zod";
import { useForm, zodResolver } from "@mantine/form";
import {
Container,
@ -9,11 +8,17 @@ import {
Button,
PasswordInput,
Box,
Anchor,
Text,
} from "@mantine/core";
import { ISetupWorkspace } from "@/features/auth/types/auth.types";
import useAuth from "@/features/auth/hooks/use-auth";
import classes from "@/features/auth/components/auth.module.css";
import { useTranslation } from "react-i18next";
import SsoCloudSignup from "@/ee/components/sso-cloud-signup.tsx";
import { isCloud } from "@/lib/config.ts";
import { Link } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route.ts";
const formSchema = z.object({
workspaceName: z.string().trim().min(3).max(50),
@ -45,55 +50,71 @@ export function SetupWorkspaceForm() {
}
return (
<Container size={420} my={40} className={classes.container}>
<Box p="xl" mt={200}>
<Title order={2} ta="center" fw={500} mb="md">
{t("Create workspace")}
</Title>
<div>
<Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md">
{t("Create workspace")}
</Title>
<form onSubmit={form.onSubmit(onSubmit)}>
<TextInput
id="workspaceName"
type="text"
label={t("Workspace Name")}
placeholder={t("e.g ACME Inc")}
variant="filled"
mt="md"
{...form.getInputProps("workspaceName")}
/>
{isCloud() && <SsoCloudSignup />}
<TextInput
id="name"
type="text"
label={t("Your Name")}
placeholder={t("enter your full name")}
variant="filled"
mt="md"
{...form.getInputProps("name")}
/>
<form onSubmit={form.onSubmit(onSubmit)}>
<TextInput
id="workspaceName"
type="text"
label={t("Workspace Name")}
placeholder={t("e.g ACME Inc")}
variant="filled"
mt="md"
{...form.getInputProps("workspaceName")}
/>
<TextInput
id="email"
type="email"
label={t("Your Email")}
placeholder="email@example.com"
variant="filled"
mt="md"
{...form.getInputProps("email")}
/>
<TextInput
id="name"
type="text"
label={t("Your Name")}
placeholder={t("enter your full name")}
variant="filled"
mt="md"
{...form.getInputProps("name")}
/>
<PasswordInput
label={t("Password")}
placeholder={t("Enter a strong password")}
variant="filled"
mt="md"
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
{t("Setup workspace")}
</Button>
</form>
</Box>
</Container>
<TextInput
id="email"
type="email"
label={t("Your Email")}
placeholder="email@example.com"
variant="filled"
mt="md"
{...form.getInputProps("email")}
/>
<PasswordInput
label={t("Password")}
placeholder={t("Enter a strong password")}
variant="filled"
mt="md"
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
{t("Create workspace")}
</Button>
</form>
</Box>
</Container>
{isCloud() && (
<Text ta="center">
{t("Already part of an existing workspace?")}{" "}
<Anchor
component={Link}
to={APP_ROUTE.AUTH.SELECT_WORKSPACE}
fw={500}
>
{t("Sign-in")}
</Anchor>
</Text>
)}
</div>
);
}

View File

@ -19,10 +19,15 @@ import {
} from "@/features/auth/types/auth.types";
import { notifications } from "@mantine/notifications";
import { IAcceptInvite } from "@/features/workspace/types/workspace.types.ts";
import { acceptInvitation } from "@/features/workspace/services/workspace-service.ts";
import {
acceptInvitation,
createWorkspace,
} from "@/features/workspace/services/workspace-service.ts";
import APP_ROUTE from "@/lib/app-route.ts";
import { RESET } from "jotai/utils";
import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts";
import { exchangeTokenRedirectUrl, getHostnameUrl } from "@/ee/utils.ts";
export default function useAuth() {
const { t } = useTranslation();
@ -67,9 +72,21 @@ export default function useAuth() {
setIsLoading(true);
try {
const res = await setupWorkspace(data);
setIsLoading(false);
navigate(APP_ROUTE.HOME);
if (isCloud()) {
const res = await createWorkspace(data);
const hostname = res?.workspace?.hostname;
const exchangeToken = res?.exchangeToken;
if (hostname && exchangeToken) {
window.location.href = exchangeTokenRedirectUrl(
hostname,
exchangeToken,
);
}
} else {
const res = await setupWorkspace(data);
setIsLoading(false);
navigate(APP_ROUTE.HOME);
}
} catch (err) {
setIsLoading(false);
notifications.show({

View File

@ -1,6 +1,7 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { getCollabToken, verifyUserToken } from "../services/auth-service";
import { ICollabToken, IVerifyUserToken } from "../types/auth.types";
import { isAxiosError } from "axios";
export function useVerifyUserTokenQuery(
verify: IVerifyUserToken,
@ -19,7 +20,13 @@ export function useCollabToken(): UseQueryResult<ICollabToken, Error> {
queryFn: () => getCollabToken(),
staleTime: 24 * 60 * 60 * 1000, //24hrs
refetchInterval: 20 * 60 * 60 * 1000, //20hrs
retry: 10,
//@ts-ignore
retry: (failureCount, error) => {
if (isAxiosError(error) && error.response.status === 404) {
return false;
}
return 10;
},
retryDelay: (retryAttempt) => {
// Exponential backoff: 5s, 10s, 20s, etc.
return 5000 * Math.pow(2, retryAttempt - 1);

View File

@ -8,6 +8,7 @@ import {
ISetupWorkspace,
IVerifyUserToken,
} from "@/features/auth/types/auth.types";
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
export async function login(data: ILogin): Promise<void> {
await api.post<void>("/auth/login", data);
@ -26,8 +27,8 @@ export async function changePassword(
export async function setupWorkspace(
data: ISetupWorkspace,
): Promise<any> {
const req = await api.post<any>("/auth/setup", data);
): Promise<IWorkspace> {
const req = await api.post<IWorkspace>("/auth/setup", data);
return req.data;
}

View File

@ -41,6 +41,7 @@ import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
import DrawioMenu from "./components/drawio/drawio-menu";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import { isCloud } from "@/lib/config.ts";
interface PageEditorProps {
pageId: string;
@ -181,6 +182,7 @@ export default function PageEditor({
}, [pageId]);
useEffect(() => {
if (isCloud()) return;
if (editable) {
if (yjsConnectionStatus === WebSocketStatus.Connected) {
editor.setEditable(true);

View File

@ -7,12 +7,22 @@ import { useTranslation } from "react-i18next";
import { formatMemberCount } from "@/lib";
import { IGroup } from "@/features/group/types/group.types.ts";
import Paginate from "@/components/common/paginate.tsx";
import { queryClient } from "@/main.tsx";
import { getSpaces } from "@/features/space/services/space-service.ts";
import { getGroupMembers } from "@/features/group/services/group-service.ts";
export default function GroupList() {
const { t } = useTranslation();
const [page, setPage] = useState(1);
const { data, isLoading } = useGetGroupsQuery({ page });
const prefetchGroupMembers = (groupId: string) => {
queryClient.prefetchQuery({
queryKey: ["groupMembers", groupId, { page: 1 }],
queryFn: () => getGroupMembers(groupId, { page: 1 }),
});
};
return (
<>
<Table.ScrollContainer minWidth={500}>
@ -27,7 +37,7 @@ export default function GroupList() {
<Table.Tbody>
{data?.items.map((group: IGroup, index: number) => (
<Table.Tr key={index}>
<Table.Td>
<Table.Td onMouseEnter={() => prefetchGroupMembers(group.id)}>
<Anchor
size="sm"
underline="never"

View File

@ -19,15 +19,30 @@ import {
import { notifications } from "@mantine/notifications";
import { IPagination, QueryParams } from "@/lib/types.ts";
import { IUser } from "@/features/user/types/user.types.ts";
import { useEffect } from "react";
import { validate as isValidUuid } from "uuid";
import { queryClient } from "@/main.tsx";
export function useGetGroupsQuery(
params?: QueryParams,
): UseQueryResult<IPagination<IGroup>, Error> {
return useQuery({
const query = useQuery({
queryKey: ["groups", params],
queryFn: () => getGroups(params),
placeholderData: keepPreviousData,
});
useEffect(() => {
if (query.data) {
if (query.data.items?.length > 0) {
query.data.items.forEach((group: IGroup) => {
queryClient.setQueryData(["group", group.id], group);
});
}
}
}, [query.data]);
return query;
}
export function useGroupQuery(groupId: string): UseQueryResult<IGroup, Error> {

View File

@ -1,6 +1,9 @@
import { Text, Avatar, SimpleGrid, Card, rem } from "@mantine/core";
import React from "react";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
import {
prefetchSpace,
useGetSpacesQuery,
} from "@/features/space/queries/space-query.ts";
import { getSpaceUrl } from "@/lib/config.ts";
import { Link } from "react-router-dom";
import classes from "./space-grid.module.css";
@ -18,6 +21,7 @@ export default function SpaceGrid() {
radius="md"
component={Link}
to={getSpaceUrl(space.slug)}
onMouseEnter={() => prefetchSpace(space.slug, space.id)}
className={classes.card}
withBorder
>

View File

@ -25,6 +25,10 @@ import {
} from "@/features/space/services/space-service.ts";
import { notifications } from "@mantine/notifications";
import { IPagination, QueryParams } from "@/lib/types.ts";
import { queryClient } from "@/main.tsx";
import { getRecentChanges } from "@/features/page/services/page-service.ts";
import { useEffect } from "react";
import { validate as isValidUuid } from "uuid";
export function useGetSpacesQuery(
params?: QueryParams,
@ -37,14 +41,39 @@ export function useGetSpacesQuery(
}
export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
return useQuery({
const query = useQuery({
queryKey: ["space", spaceId],
queryFn: () => getSpaceById(spaceId),
enabled: !!spaceId,
staleTime: 5 * 60 * 1000,
});
useEffect(() => {
if (query.data) {
if (isValidUuid(spaceId)) {
queryClient.setQueryData(["space", query.data.slug], query.data);
} else {
queryClient.setQueryData(["space", query.data.id], query.data);
}
}
}, [query.data]);
return query;
}
export const prefetchSpace = (spaceSlug: string, spaceId?: string) => {
queryClient.prefetchQuery({
queryKey: ["space", spaceSlug],
queryFn: () => getSpaceById(spaceSlug),
});
if (spaceId) {
// this endpoint only accepts uuid for now
queryClient.prefetchQuery({
queryKey: ["recent-changes", spaceId],
queryFn: () => getRecentChanges(spaceId),
});
}
};
export function useCreateSpaceMutation() {
const queryClient = useQueryClient();

View File

@ -9,16 +9,21 @@ import { SOCKET_URL } from "@/features/websocket/types";
import { useQuerySubscription } from "@/features/websocket/use-query-subscription.ts";
import { useTreeSocket } from "@/features/websocket/use-tree-socket.ts";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import { Error404 } from "@/components/ui/error-404.tsx";
export function UserProvider({ children }: React.PropsWithChildren) {
const [, setCurrentUser] = useAtom(currentUserAtom);
const { data, isLoading, error } = useCurrentUser();
const { data, isLoading, error, isError } = useCurrentUser();
const { i18n } = useTranslation();
const [, setSocket] = useAtom(socketAtom);
// fetch collab token on load
const { data: collab } = useCollabToken();
useEffect(() => {
if (isLoading || isError) {
return;
}
const newSocket = io(SOCKET_URL, {
transports: ["websocket"],
withCredentials: true,
@ -35,7 +40,7 @@ export function UserProvider({ children }: React.PropsWithChildren) {
console.log("ws disconnected");
newSocket.disconnect();
};
}, []);
}, [isError, isLoading]);
useQuerySubscription();
useTreeSocket();
@ -51,10 +56,12 @@ export function UserProvider({ children }: React.PropsWithChildren) {
if (isLoading) return <></>;
if (!data?.user && !data?.workspace) return <></>;
if (isError && error?.["response"]?.status === 404) {
return <Error404 />;
}
if (error) {
return <>an error occurred</>;
return <></>;
}
return <>{children}</>;

View File

@ -1,4 +1 @@
export const SOCKET_URL = import.meta.env.DEV
? process.env.APP_URL
: undefined;
export const SOCKET_URL = undefined

View File

@ -11,6 +11,7 @@ import { notifications } from "@mantine/notifications";
import { useClipboard } from "@mantine/hooks";
import { getInviteLink } from "@/features/workspace/services/workspace-service.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { isCloud } from "@/lib/config.ts";
interface Props {
invitationId: string;
@ -76,13 +77,16 @@ export default function InviteActionMenu({ invitationId }: Props) {
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={() => handleCopyLink(invitationId)}
leftSection={<IconCopy size={16} />}
disabled={!isAdmin}
>
{t("Copy link")}
</Menu.Item>
{!isCloud() && (
<Menu.Item
onClick={() => handleCopyLink(invitationId)}
leftSection={<IconCopy size={16} />}
disabled={!isAdmin}
>
{t("Copy link")}
</Menu.Item>
)}
<Menu.Item
onClick={onResend}
leftSection={<IconSend size={16} />}

View File

@ -9,12 +9,13 @@ export default function WorkspaceInviteSection() {
const [currentUser] = useAtom(currentUserAtom);
const [inviteLink, setInviteLink] = useState<string>("");
/*
useEffect(() => {
setInviteLink(
`${window.location.origin}/invite/${currentUser.workspace.inviteCode}`,
);
}, [currentUser.workspace.inviteCode]);
*/
return (
<>
<div>

View File

@ -1,8 +1,7 @@
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useAtom } from "jotai";
import * as z from "zod";
import { useState } from "react";
import { focusAtom } from "jotai-optics";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
import { TextInput, Button } from "@mantine/core";
@ -17,21 +16,16 @@ const formSchema = z.object({
type FormValues = z.infer<typeof formSchema>;
const workspaceAtom = focusAtom(currentUserAtom, (optic) =>
optic.prop("workspace"),
);
export default function WorkspaceNameForm() {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [currentUser] = useAtom(currentUserAtom);
const [, setWorkspace] = useAtom(workspaceAtom);
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const { isAdmin } = useUserRole();
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
initialValues: {
name: currentUser?.workspace?.name,
name: workspace?.name,
},
});
@ -39,7 +33,7 @@ export default function WorkspaceNameForm() {
setIsLoading(true);
try {
const updatedWorkspace = await updateWorkspace(data);
const updatedWorkspace = await updateWorkspace({ name: data.name });
setWorkspace(updatedWorkspace);
notifications.show({ message: t("Updated successfully") });
} catch (err) {

View File

@ -21,6 +21,7 @@ import { notifications } from "@mantine/notifications";
import {
ICreateInvite,
IInvitation,
IPublicWorkspace,
IWorkspace,
} from "@/features/workspace/types/workspace.types.ts";
import { IUser } from "@/features/user/types/user.types.ts";
@ -34,7 +35,7 @@ export function useWorkspaceQuery(): UseQueryResult<IWorkspace, Error> {
}
export function useWorkspacePublicDataQuery(): UseQueryResult<
IWorkspace,
IPublicWorkspace,
Error
> {
return useQuery({

View File

@ -5,17 +5,26 @@ import {
IInvitation,
IWorkspace,
IAcceptInvite,
IPublicWorkspace,
IInvitationLink,
} from "../types/workspace.types";
import { IPagination, QueryParams } from "@/lib/types.ts";
import { ISetupWorkspace } from "@/features/auth/types/auth.types.ts";
export async function getWorkspace(): Promise<IWorkspace> {
const req = await api.post<IWorkspace>("/workspace/info");
return req.data;
}
export async function getWorkspacePublicData(): Promise<IWorkspace> {
const req = await api.post<IWorkspace>("/workspace/public");
export async function getWorkspacePublicData(): Promise<IPublicWorkspace> {
const req = await api.post<IPublicWorkspace>("/workspace/public");
return req.data;
}
export async function getCheckHostname(
hostname: string,
): Promise<{ hostname: string }> {
const req = await api.post("/workspace/check-hostname", { hostname });
return req.data;
}
@ -81,6 +90,13 @@ export async function getInvitationById(data: {
return req.data;
}
export async function createWorkspace(
data: ISetupWorkspace,
): Promise<{ workspace: IWorkspace } & { exchangeToken: string }> {
const req = await api.post("/workspace/create", data);
return req.data;
}
export async function uploadLogo(file: File) {
const formData = new FormData();
formData.append("type", "workspace-logo");

View File

@ -1,3 +1,5 @@
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
export interface IWorkspace {
id: string;
name: string;
@ -7,10 +9,17 @@ export interface IWorkspace {
defaultSpaceId: string;
customDomain: string;
enableInvite: boolean;
inviteCode: string;
settings: any;
status: string;
enforceSso: boolean;
billingEmail: string;
trialEndAt: Date;
createdAt: Date;
updatedAt: Date;
emailDomains: string[];
memberCount?: number;
plan?: string;
hasLicenseKey?: boolean;
}
export interface ICreateInvite {
@ -38,3 +47,13 @@ export interface IAcceptInvite {
password: string;
token: string;
}
export interface IPublicWorkspace {
id: string;
name: string;
logo: string;
hostname: string;
enforceSso: boolean;
authProviders: IAuthProvider[];
hasLicenseKey?: boolean;
}

View File

@ -1,5 +1,6 @@
import axios, { AxiosInstance } from "axios";
import APP_ROUTE from "@/lib/app-route.ts";
import { isCloud } from "@/lib/config.ts";
const api: AxiosInstance = axios.create({
baseURL: "/api",
@ -41,7 +42,10 @@ api.interceptors.response.use(
.includes("workspace not found")
) {
console.log("workspace not found");
if (window.location.pathname != APP_ROUTE.AUTH.SETUP) {
if (
!isCloud() &&
window.location.pathname != APP_ROUTE.AUTH.SETUP
) {
window.location.href = APP_ROUTE.AUTH.SETUP;
}
}

View File

@ -6,6 +6,8 @@ const APP_ROUTE = {
SETUP: "/setup/register",
FORGOT_PASSWORD: "/forgot-password",
PASSWORD_RESET: "/password-reset",
CREATE_WORKSPACE: "/create",
SELECT_WORKSPACE: "/select",
},
SETTINGS: {
ACCOUNT: {
@ -17,6 +19,8 @@ const APP_ROUTE = {
MEMBERS: "/settings/members",
GROUPS: "/settings/groups",
SPACES: "/settings/spaces",
BILLING: "/settings/billing",
SECURITY: "/settings/security",
},
},
};

View File

@ -1,4 +1,5 @@
import bytes from "bytes";
import { castToBoolean } from "@/lib/utils.tsx";
declare global {
interface Window {
@ -14,6 +15,10 @@ export function getAppUrl(): string {
return `${window.location.protocol}//${window.location.host}`;
}
export function getServerAppUrl(): string {
return getConfigValue("APP_URL");
}
export function getBackendUrl(): string {
return getAppUrl() + "/api";
}
@ -28,6 +33,14 @@ export function getCollaborationUrl(): string {
return collabUrl.toString();
}
export function getSubdomainHost(): string {
return getConfigValue("SUBDOMAIN_HOST");
}
export function isCloud(): boolean {
return castToBoolean(getConfigValue("CLOUD"));
}
export function getAvatarUrl(avatarUrl: string) {
if (!avatarUrl) return null;
if (avatarUrl?.startsWith("http")) return avatarUrl;

View File

@ -93,3 +93,33 @@ export function getPageIcon(icon: string, size = 18): string | ReactNode {
)
);
}
export function castToBoolean(value: unknown): boolean {
if (value == null) {
return false;
}
if (typeof value === "boolean") {
return value;
}
if (typeof value === "number") {
return value !== 0;
}
if (typeof value === "string") {
const trimmed = value.trim().toLowerCase();
const trueValues = ["true", "1"];
const falseValues = ["false", "0"];
if (trueValues.includes(trimmed)) {
return true;
}
if (falseValues.includes(trimmed)) {
return false;
}
return Boolean(trimmed);
}
return Boolean(value);
}

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 { theme } from "@/theme";
import { mantineCssResolver, theme } from '@/theme';
import { MantineProvider } from "@mantine/core";
import { BrowserRouter } from "react-router-dom";
import { ModalsProvider } from "@mantine/modals";
@ -18,6 +18,7 @@ export const queryClient = new QueryClient({
refetchOnMount: false,
refetchOnWindowFocus: false,
retry: false,
staleTime: 5 * 60 * 1000,
},
},
});
@ -29,7 +30,7 @@ const root = ReactDOM.createRoot(
root.render(
<BrowserRouter>
<MantineProvider theme={theme}>
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
<ModalsProvider>
<QueryClientProvider client={queryClient}>
<Notifications position="bottom-center" limit={3} />

View File

@ -1,6 +1,6 @@
import { LoginForm } from "@/features/auth/components/login-form";
import { Helmet } from "react-helmet-async";
import {getAppName} from "@/lib/config.ts";
import { getAppName } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
export default function LoginPage() {
@ -9,7 +9,9 @@ export default function LoginPage() {
return (
<>
<Helmet>
<title>{t("Login")} - {getAppName()}</title>
<title>
{t("Login")} - {getAppName()}
</title>
</Helmet>
<LoginForm />
</>

View File

@ -3,7 +3,8 @@ import { SetupWorkspaceForm } from "@/features/auth/components/setup-workspace-f
import { Helmet } from "react-helmet-async";
import React, { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import {getAppName} from "@/lib/config.ts";
import APP_ROUTE from "@/lib/app-route.ts";
import { getAppName } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
export default function SetupWorkspace() {
@ -18,10 +19,10 @@ export default function SetupWorkspace() {
const navigate = useNavigate();
useEffect(() => {
if (!isLoading && !isError && workspace) {
navigate("/");
if (!isLoading && workspace) {
navigate(APP_ROUTE.AUTH.LOGIN);
}
}, [isLoading, isError, workspace]);
}, [isLoading, workspace]);
if (isLoading) {
return <div></div>;
@ -35,7 +36,9 @@ export default function SetupWorkspace() {
return (
<>
<Helmet>
<title>{t("Setup Workspace")} - {getAppName()}</title>
<title>
{t("Setup Workspace")} - {getAppName()}
</title>
</Helmet>
<SetupWorkspaceForm />
</>

View File

@ -1,22 +1,27 @@
import {Container, Space} from "@mantine/core";
import { Container, Space } from "@mantine/core";
import HomeTabs from "@/features/home/components/home-tabs";
import SpaceGrid from "@/features/space/components/space-grid.tsx";
import {getAppName} from "@/lib/config.ts";
import {Helmet} from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
export default function Home() {
return (
<>
<Helmet>
<title>Home - {getAppName()}</title>
</Helmet>
<Container size={"800"} pt="xl">
<SpaceGrid/>
const { t } = useTranslation();
<Space h="xl"/>
return (
<>
<Helmet>
<title>
{t("Home")} - {getAppName()}
</title>
</Helmet>
<Container size={"800"} pt="xl">
<SpaceGrid />
<HomeTabs/>
</Container>
</>
);
<Space h="xl" />
<HomeTabs />
</Container>
</>
);
}

View File

@ -1,69 +1,77 @@
import WorkspaceInviteModal from "@/features/workspace/components/members/components/workspace-invite-modal";
import {Group, SegmentedControl, Space, Text} from "@mantine/core";
import { Group, SegmentedControl, Space, Text } from "@mantine/core";
import WorkspaceMembersTable from "@/features/workspace/components/members/components/workspace-members-table";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import {useEffect, useState} from "react";
import {useNavigate, useSearchParams} from "react-router-dom";
import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import WorkspaceInvitesTable from "@/features/workspace/components/members/components/workspace-invites-table.tsx";
import useUserRole from "@/hooks/use-user-role.tsx";
import {getAppName} from "@/lib/config.ts";
import {Helmet} from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
export default function WorkspaceMembers() {
const [segmentValue, setSegmentValue] = useState("members");
const [searchParams] = useSearchParams();
const {isAdmin} = useUserRole();
const navigate = useNavigate();
const { t } = useTranslation();
const { t } = useTranslation();
const [segmentValue, setSegmentValue] = useState("members");
const [workspace] = useAtom(workspaceAtom);
const [searchParams] = useSearchParams();
const { isAdmin } = useUserRole();
const navigate = useNavigate();
useEffect(() => {
const currentTab = searchParams.get("tab");
if (currentTab === "invites") {
setSegmentValue(currentTab);
}
}, [searchParams.get("tab")]);
useEffect(() => {
const currentTab = searchParams.get("tab");
if (currentTab === "invites") {
setSegmentValue(currentTab);
}
}, [searchParams.get("tab")]);
const handleSegmentChange = (value: string) => {
setSegmentValue(value);
if (value === "invites") {
navigate(`?tab=${value}`);
} else {
navigate("");
}
};
const handleSegmentChange = (value: string) => {
setSegmentValue(value);
if (value === "invites") {
navigate(`?tab=${value}`);
} else {
navigate("");
}
};
return (
<>
<Helmet>
<title>{t("Members")} - {getAppName()}</title>
</Helmet>
<SettingsTitle title={t("Members")}/>
return (
<>
<Helmet>
<title>
{t("Members")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Members")} />
{/* <WorkspaceInviteSection /> */}
{/* <Divider my="lg" /> */}
{/* <WorkspaceInviteSection /> */}
{/* <Divider my="lg" /> */}
<Group justify="space-between">
<SegmentedControl
value={segmentValue}
onChange={handleSegmentChange}
data={[
{ label: t("Members"), value: "members" },
{ label: t("Pending"), value: "invites" },
]}
withItemsBorders={false}
/>
<Group justify="space-between">
<SegmentedControl
value={segmentValue}
onChange={handleSegmentChange}
data={[
{
label: t("Members") + ` (${workspace?.memberCount})`,
value: "members",
},
{ label: t("Pending"), value: "invites" },
]}
withItemsBorders={false}
/>
{isAdmin && <WorkspaceInviteModal/>}
</Group>
{isAdmin && <WorkspaceInviteModal />}
</Group>
<Space h="lg"/>
<Space h="lg" />
{segmentValue === "invites" ? (
<WorkspaceInvitesTable/>
) : (
<WorkspaceMembersTable/>
)}
</>
);
{segmentValue === "invites" ? (
<WorkspaceInvitesTable />
) : (
<WorkspaceMembersTable />
)}
</>
);
}

View File

@ -1,18 +1,27 @@
import SettingsTitle from "@/components/settings/settings-title.tsx";
import WorkspaceNameForm from "@/features/workspace/components/settings/components/workspace-name-form";
import { useTranslation } from "react-i18next";
import {getAppName} from "@/lib/config.ts";
import {Helmet} from "react-helmet-async";
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";
export default function WorkspaceSettings() {
const { t } = useTranslation();
return (
return (
<>
<Helmet>
<title>Workspace Settings - {getAppName()}</title>
</Helmet>
<SettingsTitle title={t("General")} />
<WorkspaceNameForm />
{isCloud() && (
<>
<Helmet>
<title>Workspace Settings - {getAppName()}</title>
</Helmet>
<SettingsTitle title={t("General")} />
<WorkspaceNameForm/>
<Divider my="md" />
<ManageHostname />
</>
);
)}
</>
);
}

View File

@ -1,29 +1,33 @@
import { createTheme, MantineColorsTuple } from '@mantine/core';
import {
createTheme,
CSSVariablesResolver,
MantineColorsTuple,
} from "@mantine/core";
const blue: MantineColorsTuple = [
'#e7f3ff',
'#d0e4ff',
'#a1c6fa',
'#6ea6f6',
'#458bf2',
'#2b7af1',
'#0b60d8',
'#1b72f2',
'#0056c1',
'#004aac',
"#e7f3ff",
"#d0e4ff",
"#a1c6fa",
"#6ea6f6",
"#458bf2",
"#2b7af1",
"#0b60d8",
"#1b72f2",
"#0056c1",
"#004aac",
];
const red: MantineColorsTuple = [
'#ffebeb',
'#fad7d7',
'#eeadad',
'#e3807f',
'#da5a59',
'#d54241',
'#d43535',
'#bc2727',
'#a82022',
'#93151b',
"#ffebeb",
"#fad7d7",
"#eeadad",
"#e3807f",
"#da5a59",
"#d54241",
"#d43535",
"#bc2727",
"#a82022",
"#93151b",
];
export const theme = createTheme({
@ -32,3 +36,11 @@ export const theme = createTheme({
red,
},
});
export const mantineCssResolver: CSSVariablesResolver = (theme) => ({
variables: {
"--input-error-size": theme.fontSizes.sm,
},
light: {},
dark: {},
});

View File

@ -5,11 +5,14 @@ import * as path from "path";
export const envPath = path.resolve(process.cwd(), "..", "..");
export default defineConfig(({ mode }) => {
const { APP_URL, FILE_UPLOAD_SIZE_LIMIT, DRAWIO_URL, COLLAB_URL } = loadEnv(
mode,
envPath,
"",
);
const {
APP_URL,
FILE_UPLOAD_SIZE_LIMIT,
DRAWIO_URL,
CLOUD,
SUBDOMAIN_HOST,
COLLAB_URL,
} = loadEnv(mode, envPath, "");
return {
define: {
@ -17,6 +20,8 @@ export default defineConfig(({ mode }) => {
APP_URL,
FILE_UPLOAD_SIZE_LIMIT,
DRAWIO_URL,
CLOUD,
SUBDOMAIN_HOST,
COLLAB_URL,
},
APP_VERSION: JSON.stringify(process.env.npm_package_version),
@ -31,7 +36,17 @@ export default defineConfig(({ mode }) => {
proxy: {
"/api": {
target: APP_URL,
changeOrigin: true,
changeOrigin: false,
},
"/socket.io": {
target: APP_URL,
ws: true,
rewriteWsOrigin: true,
},
"/collab": {
target: APP_URL,
ws: true,
rewriteWsOrigin: true,
},
},
},

Some files were not shown because too many files have changed in this diff Show More