mirror of
https://github.com/docmost/docmost.git
synced 2025-11-10 08:02:04 +10:00
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:
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,4 +1,6 @@
|
|||||||
.env
|
.env
|
||||||
|
.env.dev
|
||||||
|
.env.prod
|
||||||
data
|
data
|
||||||
# compiled output
|
# compiled output
|
||||||
/dist
|
/dist
|
||||||
|
|||||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[submodule "apps/server/src/ee"]
|
||||||
|
path = apps/server/src/ee
|
||||||
|
url = https://github.com/docmost/ee
|
||||||
@ -16,12 +16,12 @@
|
|||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@excalidraw/excalidraw": "^0.17.6",
|
"@excalidraw/excalidraw": "^0.17.6",
|
||||||
"@mantine/core": "^7.14.2",
|
"@mantine/core": "^7.17.0",
|
||||||
"@mantine/form": "^7.14.2",
|
"@mantine/form": "^7.17.0",
|
||||||
"@mantine/hooks": "^7.14.2",
|
"@mantine/hooks": "^7.17.0",
|
||||||
"@mantine/modals": "^7.14.2",
|
"@mantine/modals": "^7.17.0",
|
||||||
"@mantine/notifications": "^7.14.2",
|
"@mantine/notifications": "^7.17.0",
|
||||||
"@mantine/spotlight": "^7.14.2",
|
"@mantine/spotlight": "^7.17.0",
|
||||||
"@tabler/icons-react": "^3.22.0",
|
"@tabler/icons-react": "^3.22.0",
|
||||||
"@tanstack/react-query": "^5.61.4",
|
"@tanstack/react-query": "^5.61.4",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
@ -30,7 +30,7 @@
|
|||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"i18next": "^23.14.0",
|
"i18next": "^23.14.0",
|
||||||
"i18next-http-backend": "^2.6.1",
|
"i18next-http-backend": "^2.6.1",
|
||||||
"jotai": "^2.10.3",
|
"jotai": "^2.12.1",
|
||||||
"jotai-optics": "^0.4.0",
|
"jotai-optics": "^0.4.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"katex": "0.16.21",
|
"katex": "0.16.21",
|
||||||
|
|||||||
@ -18,10 +18,18 @@ import { ErrorBoundary } from "react-error-boundary";
|
|||||||
import InviteSignup from "@/pages/auth/invite-signup.tsx";
|
import InviteSignup from "@/pages/auth/invite-signup.tsx";
|
||||||
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
|
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
|
||||||
import PasswordReset from "./pages/auth/password-reset";
|
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 { 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() {
|
export default function App() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
useRedirectToCloudSelect();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -29,15 +37,24 @@ export default function App() {
|
|||||||
<Route index element={<Navigate to="/home" />} />
|
<Route index element={<Navigate to="/home" />} />
|
||||||
<Route path={"/login"} element={<LoginPage />} />
|
<Route path={"/login"} element={<LoginPage />} />
|
||||||
<Route path={"/invites/:invitationId"} element={<InviteSignup />} />
|
<Route path={"/invites/:invitationId"} element={<InviteSignup />} />
|
||||||
<Route path={"/setup/register"} element={<SetupWorkspace />} />
|
|
||||||
<Route path={"/forgot-password"} element={<ForgotPassword />} />
|
<Route path={"/forgot-password"} element={<ForgotPassword />} />
|
||||||
<Route path={"/password-reset"} element={<PasswordReset />} />
|
<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 path={"/p/:pageSlug"} element={<PageRedirect />} />
|
||||||
|
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route path={"/home"} element={<Home />} />
|
<Route path={"/home"} element={<Home />} />
|
||||||
|
|
||||||
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
|
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
|
||||||
<Route
|
<Route
|
||||||
path={"/s/:spaceSlug/p/:pageSlug"}
|
path={"/s/:spaceSlug/p/:pageSlug"}
|
||||||
@ -61,6 +78,9 @@ export default function App() {
|
|||||||
<Route path={"groups"} element={<Groups />} />
|
<Route path={"groups"} element={<Groups />} />
|
||||||
<Route path={"groups/:groupId"} element={<GroupInfo />} />
|
<Route path={"groups/:groupId"} element={<GroupInfo />} />
|
||||||
<Route path={"spaces"} element={<Spaces />} />
|
<Route path={"spaces"} element={<Spaces />} />
|
||||||
|
<Route path={"security"} element={<Security />} />
|
||||||
|
{!isCloud() && <Route path={"license"} element={<License />} />}
|
||||||
|
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|||||||
31
apps/client/src/components/common/copy.tsx
Normal file
31
apps/client/src/components/common/copy.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
apps/client/src/components/icons/google-icon.tsx
Normal file
33
apps/client/src/components/icons/google-icon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/client/src/components/icons/openid-icon.tsx
Normal file
20
apps/client/src/components/icons/openid-icon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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 classes from "./app-header.module.css";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import TopMenu from "@/components/layouts/global/top-menu.tsx";
|
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 APP_ROUTE from "@/lib/app-route.ts";
|
||||||
import {useAtom} from "jotai/index";
|
import { useAtom } from "jotai";
|
||||||
import {
|
import {
|
||||||
desktopSidebarAtom,
|
desktopSidebarAtom,
|
||||||
mobileSidebarAtom,
|
mobileSidebarAtom,
|
||||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
} 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 SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
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() {
|
export function AppHeader() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -22,6 +24,7 @@ export function AppHeader() {
|
|||||||
|
|
||||||
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
||||||
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
|
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
|
||||||
|
const { isTrial, trialDaysLeft } = useTrial();
|
||||||
|
|
||||||
const isHomeRoute = location.pathname.startsWith("/home");
|
const isHomeRoute = location.pathname.startsWith("/home");
|
||||||
|
|
||||||
@ -38,7 +41,6 @@ export function AppHeader() {
|
|||||||
{!isHomeRoute && (
|
{!isHomeRoute && (
|
||||||
<>
|
<>
|
||||||
<Tooltip label={t("Sidebar toggle")}>
|
<Tooltip label={t("Sidebar toggle")}>
|
||||||
|
|
||||||
<SidebarToggle
|
<SidebarToggle
|
||||||
aria-label={t("Sidebar toggle")}
|
aria-label={t("Sidebar toggle")}
|
||||||
opened={mobileOpened}
|
opened={mobileOpened}
|
||||||
@ -63,7 +65,7 @@ export function AppHeader() {
|
|||||||
<Text
|
<Text
|
||||||
size="lg"
|
size="lg"
|
||||||
fw={600}
|
fw={600}
|
||||||
style={{cursor: "pointer", userSelect: "none"}}
|
style={{ cursor: "pointer", userSelect: "none" }}
|
||||||
component={Link}
|
component={Link}
|
||||||
to="/home"
|
to="/home"
|
||||||
>
|
>
|
||||||
@ -75,8 +77,21 @@ export function AppHeader() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group px={"xl"}>
|
<Group px={"xl"} wrap="nowrap">
|
||||||
<TopMenu/>
|
{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>
|
||||||
</Group>
|
</Group>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -1,23 +1,26 @@
|
|||||||
import { AppShell, Container } from "@mantine/core";
|
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 { useLocation } from "react-router-dom";
|
||||||
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import {
|
import {
|
||||||
asideStateAtom,
|
asideStateAtom,
|
||||||
desktopSidebarAtom,
|
desktopSidebarAtom,
|
||||||
mobileSidebarAtom, sidebarWidthAtom,
|
mobileSidebarAtom,
|
||||||
|
sidebarWidthAtom,
|
||||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
|
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
|
||||||
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
||||||
import Aside from "@/components/layouts/global/aside.tsx";
|
import Aside from "@/components/layouts/global/aside.tsx";
|
||||||
import classes from "./app-shell.module.css";
|
import classes from "./app-shell.module.css";
|
||||||
|
import { useTrialEndAction } from "@/ee/hooks/use-trial-end-action.tsx";
|
||||||
|
|
||||||
export default function GlobalAppShell({
|
export default function GlobalAppShell({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
useTrialEndAction();
|
||||||
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
||||||
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
||||||
const [{ isAsideOpen }] = useAtom(asideStateAtom);
|
const [{ isAsideOpen }] = useAtom(asideStateAtom);
|
||||||
@ -37,7 +40,9 @@ export default function GlobalAppShell({
|
|||||||
const resize = React.useCallback(
|
const resize = React.useCallback(
|
||||||
(mouseMoveEvent) => {
|
(mouseMoveEvent) => {
|
||||||
if (isResizing) {
|
if (isResizing) {
|
||||||
const newWidth = mouseMoveEvent.clientX - sidebarRef.current.getBoundingClientRect().left;
|
const newWidth =
|
||||||
|
mouseMoveEvent.clientX -
|
||||||
|
sidebarRef.current.getBoundingClientRect().left;
|
||||||
if (newWidth < 220) {
|
if (newWidth < 220) {
|
||||||
setSidebarWidth(220);
|
setSidebarWidth(220);
|
||||||
return;
|
return;
|
||||||
@ -49,7 +54,7 @@ export default function GlobalAppShell({
|
|||||||
setSidebarWidth(newWidth);
|
setSidebarWidth(newWidth);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isResizing]
|
[isResizing],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -94,7 +99,11 @@ export default function GlobalAppShell({
|
|||||||
<AppHeader />
|
<AppHeader />
|
||||||
</AppShell.Header>
|
</AppShell.Header>
|
||||||
{!isHomeRoute && (
|
{!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} />
|
<div className={classes.resizeHandle} onMouseDown={startResizing} />
|
||||||
{isSpaceRoute && <SpaceSidebar />}
|
{isSpaceRoute && <SpaceSidebar />}
|
||||||
{isSettingsRoute && <SettingsSidebar />}
|
{isSettingsRoute && <SettingsSidebar />}
|
||||||
|
|||||||
@ -33,13 +33,13 @@ export default function TopMenu() {
|
|||||||
<UnstyledButton>
|
<UnstyledButton>
|
||||||
<Group gap={7} wrap={"nowrap"}>
|
<Group gap={7} wrap={"nowrap"}>
|
||||||
<CustomAvatar
|
<CustomAvatar
|
||||||
avatarUrl={workspace.logo}
|
avatarUrl={workspace?.logo}
|
||||||
name={workspace.name}
|
name={workspace?.name}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
<Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}>
|
<Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}>
|
||||||
{workspace.name}
|
{workspace?.name}
|
||||||
</Text>
|
</Text>
|
||||||
<IconChevronDown size={16} />
|
<IconChevronDown size={16} />
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
51
apps/client/src/components/settings/settings-queries.tsx
Normal file
51
apps/client/src/components/settings/settings-queries.tsx
Normal 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(),
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { Group, Text, ScrollArea, ActionIcon, rem } from "@mantine/core";
|
import { Group, Text, ScrollArea, ActionIcon } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
IconUser,
|
IconUser,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
@ -8,15 +8,33 @@ import {
|
|||||||
IconUsersGroup,
|
IconUsersGroup,
|
||||||
IconSpaces,
|
IconSpaces,
|
||||||
IconBrush,
|
IconBrush,
|
||||||
|
IconCoin,
|
||||||
|
IconLock,
|
||||||
|
IconKey,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||||
import classes from "./settings.module.css";
|
import classes from "./settings.module.css";
|
||||||
import { useTranslation } from "react-i18next";
|
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 {
|
interface DataItem {
|
||||||
label: string;
|
label: string;
|
||||||
icon: React.ElementType;
|
icon: React.ElementType;
|
||||||
path: string;
|
path: string;
|
||||||
|
isCloud?: boolean;
|
||||||
|
isEnterprise?: boolean;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
isSelfhosted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DataGroup {
|
interface DataGroup {
|
||||||
@ -45,10 +63,35 @@ const groupedData: DataGroup[] = [
|
|||||||
icon: IconUsers,
|
icon: IconUsers,
|
||||||
path: "/settings/members",
|
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: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
||||||
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
|
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
heading: "System",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: "License & Edition",
|
||||||
|
icon: IconKey,
|
||||||
|
path: "/settings/license",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function SettingsSidebar() {
|
export default function SettingsSidebar() {
|
||||||
@ -56,18 +99,79 @@ export default function SettingsSidebar() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [active, setActive] = useState(location.pathname);
|
const [active, setActive] = useState(location.pathname);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { isAdmin } = useUserRole();
|
||||||
|
const [workspace] = useAtom(workspaceAtom);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActive(location.pathname);
|
setActive(location.pathname);
|
||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
|
|
||||||
const menuItems = groupedData.map((group) => (
|
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}>
|
<div key={group.heading}>
|
||||||
<Text c="dimmed" className={classes.linkHeader}>
|
<Text c="dimmed" className={classes.linkHeader}>
|
||||||
{t(group.heading)}
|
{t(group.heading)}
|
||||||
</Text>
|
</Text>
|
||||||
{group.items.map((item) => (
|
{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
|
<Link
|
||||||
|
onMouseEnter={prefetchHandler}
|
||||||
className={classes.link}
|
className={classes.link}
|
||||||
data-active={active.startsWith(item.path) || undefined}
|
data-active={active.startsWith(item.path) || undefined}
|
||||||
key={item.label}
|
key={item.label}
|
||||||
@ -76,9 +180,11 @@ export default function SettingsSidebar() {
|
|||||||
<item.icon className={classes.linkIcon} stroke={2} />
|
<item.icon className={classes.linkIcon} stroke={2} />
|
||||||
<span>{t(item.label)}</span>
|
<span>{t(item.label)}</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
));
|
);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.navbar}>
|
<div className={classes.navbar}>
|
||||||
@ -95,9 +201,8 @@ export default function SettingsSidebar() {
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<ScrollArea w="100%">{menuItems}</ScrollArea>
|
<ScrollArea w="100%">{menuItems}</ScrollArea>
|
||||||
<div className={classes.version}>
|
<div className={classes.text}>
|
||||||
<Text
|
<Text
|
||||||
className={classes.version}
|
|
||||||
size="sm"
|
size="sm"
|
||||||
c="dimmed"
|
c="dimmed"
|
||||||
component="a"
|
component="a"
|
||||||
@ -107,6 +212,19 @@ export default function SettingsSidebar() {
|
|||||||
v{APP_VERSION}
|
v{APP_VERSION}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isCloud() && (
|
||||||
|
<div className={classes.text}>
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
c="dimmed"
|
||||||
|
component="a"
|
||||||
|
href="mailto:help@docmost.com"
|
||||||
|
>
|
||||||
|
help@docmost.com
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,7 +58,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.version {
|
.text {
|
||||||
padding-left: var(--mantine-spacing-xs) ;
|
padding-left: var(--mantine-spacing-xs) ;
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
}
|
}
|
||||||
|
|||||||
1
apps/client/src/ee/LICENSE
Normal file
1
apps/client/src/ee/LICENSE
Normal file
@ -0,0 +1 @@
|
|||||||
|
Files in this directory are subject to the Docmost Enterprise Software license.
|
||||||
130
apps/client/src/ee/billing/components/billing-details.tsx
Normal file
130
apps/client/src/ee/billing/components/billing-details.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
apps/client/src/ee/billing/components/billing-incomplete.tsx
Normal file
13
apps/client/src/ee/billing/components/billing-incomplete.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
apps/client/src/ee/billing/components/billing-plans.tsx
Normal file
115
apps/client/src/ee/billing/components/billing-plans.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
apps/client/src/ee/billing/components/billing-trial.tsx
Normal file
32
apps/client/src/ee/billing/components/billing-trial.tsx
Normal 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>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
apps/client/src/ee/billing/components/billing.module.css
Normal file
10
apps/client/src/ee/billing/components/billing.module.css
Normal 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);
|
||||||
|
}
|
||||||
34
apps/client/src/ee/billing/components/manage-billing.tsx
Normal file
34
apps/client/src/ee/billing/components/manage-billing.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
apps/client/src/ee/billing/pages/billing.tsx
Normal file
41
apps/client/src/ee/billing/pages/billing.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/client/src/ee/billing/queries/billing-query.ts
Normal file
20
apps/client/src/ee/billing/queries/billing-query.ts
Normal 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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
29
apps/client/src/ee/billing/services/billing-service.ts
Normal file
29
apps/client/src/ee/billing/services/billing-service.ts
Normal 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;
|
||||||
|
}
|
||||||
49
apps/client/src/ee/billing/types/billing.types.ts
Normal file
49
apps/client/src/ee/billing/types/billing.types.ts
Normal 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[];
|
||||||
|
}
|
||||||
17
apps/client/src/ee/billing/utils.ts
Normal file
17
apps/client/src/ee/billing/utils.ts
Normal 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;
|
||||||
|
}
|
||||||
13
apps/client/src/ee/cloud/query/cloud-query.ts
Normal file
13
apps/client/src/ee/cloud/query/cloud-query.ts
Normal 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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
7
apps/client/src/ee/cloud/service/cloud-service.ts
Normal file
7
apps/client/src/ee/cloud/service/cloud-service.ts
Normal 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;
|
||||||
|
}
|
||||||
96
apps/client/src/ee/components/cloud-login-form.tsx
Normal file
96
apps/client/src/ee/components/cloud-login-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
apps/client/src/ee/components/joined-workspaces.module.css
Normal file
13
apps/client/src/ee/components/joined-workspaces.module.css
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
49
apps/client/src/ee/components/joined-workspaces.tsx
Normal file
49
apps/client/src/ee/components/joined-workspaces.tsx
Normal 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>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
apps/client/src/ee/components/manage-hostname.tsx
Normal file
119
apps/client/src/ee/components/manage-hostname.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
apps/client/src/ee/components/sso-cloud-signup.tsx
Normal file
25
apps/client/src/ee/components/sso-cloud-signup.tsx
Normal 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" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
apps/client/src/ee/components/sso-login.tsx
Normal file
57
apps/client/src/ee/components/sso-login.tsx
Normal 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" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/client/src/ee/hooks/use-plan.tsx
Normal file
15
apps/client/src/ee/hooks/use-plan.tsx
Normal 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;
|
||||||
20
apps/client/src/ee/hooks/use-redirect-to-cloud-select.tsx
Normal file
20
apps/client/src/ee/hooks/use-redirect-to-cloud-select.tsx
Normal 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]);
|
||||||
|
};
|
||||||
36
apps/client/src/ee/hooks/use-trial-end-action.tsx
Normal file
36
apps/client/src/ee/hooks/use-trial-end-action.tsx
Normal 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]);
|
||||||
|
};
|
||||||
16
apps/client/src/ee/hooks/use-trial.tsx
Normal file
16
apps/client/src/ee/hooks/use-trial.tsx
Normal 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;
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
apps/client/src/ee/licence/components/license-details.tsx
Normal file
81
apps/client/src/ee/licence/components/license-details.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
export default function LicenseMessage() {
|
||||||
|
return <>To unlock enterprise features, please contact sales@docmost.com to purchase a license.</>;
|
||||||
|
}
|
||||||
39
apps/client/src/ee/licence/components/oss-details.tsx
Normal file
39
apps/client/src/ee/licence/components/oss-details.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
apps/client/src/ee/licence/components/remove-license.tsx
Normal file
33
apps/client/src/ee/licence/components/remove-license.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
26
apps/client/src/ee/licence/license.utils.ts
Normal file
26
apps/client/src/ee/licence/license.utils.ts
Normal 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;
|
||||||
|
}
|
||||||
35
apps/client/src/ee/licence/pages/license.tsx
Normal file
35
apps/client/src/ee/licence/pages/license.tsx
Normal 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 />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
apps/client/src/ee/licence/queries/license-query.ts
Normal file
52
apps/client/src/ee/licence/queries/license-query.ts
Normal 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"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
18
apps/client/src/ee/licence/services/license-service.ts
Normal file
18
apps/client/src/ee/licence/services/license-service.ts
Normal 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");
|
||||||
|
}
|
||||||
8
apps/client/src/ee/licence/types/license.types.ts
Normal file
8
apps/client/src/ee/licence/types/license.types.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export interface ILicenseInfo {
|
||||||
|
id: string;
|
||||||
|
customerName: string;
|
||||||
|
seatCount: number;
|
||||||
|
issuedAt: Date;
|
||||||
|
expiresAt: Date;
|
||||||
|
trial: boolean;
|
||||||
|
}
|
||||||
20
apps/client/src/ee/pages/cloud-login.tsx
Normal file
20
apps/client/src/ee/pages/cloud-login.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/client/src/ee/pages/create-workspace.tsx
Normal file
15
apps/client/src/ee/pages/create-workspace.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
apps/client/src/ee/security/components/allowed-domains.tsx
Normal file
88
apps/client/src/ee/security/components/allowed-domains.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
apps/client/src/ee/security/components/enforce-sso.tsx
Normal file
61
apps/client/src/ee/security/components/enforce-sso.tsx
Normal 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")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
apps/client/src/ee/security/components/sso-google-form.tsx
Normal file
91
apps/client/src/ee/security/components/sso-google-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
apps/client/src/ee/security/components/sso-oidc-form.tsx
Normal file
140
apps/client/src/ee/security/components/sso-oidc-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
186
apps/client/src/ee/security/components/sso-provider-list.tsx
Normal file
186
apps/client/src/ee/security/components/sso-provider-list.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
apps/client/src/ee/security/components/sso-saml-form.tsx
Normal file
153
apps/client/src/ee/security/components/sso-saml-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
apps/client/src/ee/security/components/sso.module.css
Normal file
14
apps/client/src/ee/security/components/sso.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
5
apps/client/src/ee/security/contants.ts
Normal file
5
apps/client/src/ee/security/contants.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export enum SSO_PROVIDER {
|
||||||
|
OIDC = 'oidc',
|
||||||
|
SAML = 'saml',
|
||||||
|
GOOGLE = 'google',
|
||||||
|
}
|
||||||
52
apps/client/src/ee/security/pages/security.tsx
Normal file
52
apps/client/src/ee/security/pages/security.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
apps/client/src/ee/security/queries/security-query.ts
Normal file
88
apps/client/src/ee/security/queries/security-query.ts
Normal 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" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
32
apps/client/src/ee/security/services/security-service.ts
Normal file
32
apps/client/src/ee/security/services/security-service.ts
Normal 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;
|
||||||
|
}
|
||||||
39
apps/client/src/ee/security/sso.utils.ts
Normal file
39
apps/client/src/ee/security/sso.utils.ts
Normal 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`;
|
||||||
|
}
|
||||||
20
apps/client/src/ee/security/types/security.types.ts
Normal file
20
apps/client/src/ee/security/types/security.types.ts
Normal 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;
|
||||||
|
}
|
||||||
16
apps/client/src/ee/utils.ts
Normal file
16
apps/client/src/ee/utils.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -2,4 +2,17 @@
|
|||||||
box-shadow: rgba(0, 0, 0, 0.07) 0px 2px 45px 4px;
|
box-shadow: rgba(0, 0, 0, 0.07) 0px 2px 45px 4px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: light-dark(var(--mantine-color-body), rgba(0, 0, 0, 0.1));
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -35,8 +35,8 @@ export function ForgotPasswordForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size={420} my={40} className={classes.container}>
|
<Container size={420} className={classes.container}>
|
||||||
<Box p="xl" mt={200}>
|
<Box p="xl" className={classes.containerBox}>
|
||||||
<Title order={2} ta="center" fw={500} mb="md">
|
<Title order={2} ta="center" fw={500} mb="md">
|
||||||
{t("Forgot password")}
|
{t("Forgot password")}
|
||||||
</Title>
|
</Title>
|
||||||
|
|||||||
@ -65,8 +65,8 @@ export function InviteSignUpForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size={420} my={40} className={classes.container}>
|
<Container size={420} className={classes.container}>
|
||||||
<Box p="xl" mt={200}>
|
<Box p="xl" className={classes.containerBox}>
|
||||||
<Title order={2} ta="center" fw={500} mb="md">
|
<Title order={2} ta="center" fw={500} mb="md">
|
||||||
{t("Join the workspace")}
|
{t("Join the workspace")}
|
||||||
</Title>
|
</Title>
|
||||||
|
|||||||
@ -10,12 +10,17 @@ import {
|
|||||||
PasswordInput,
|
PasswordInput,
|
||||||
Box,
|
Box,
|
||||||
Anchor,
|
Anchor,
|
||||||
|
Group,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import classes from "./auth.module.css";
|
import classes from "./auth.module.css";
|
||||||
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
|
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 APP_ROUTE from "@/lib/app-route.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
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({
|
const formSchema = z.object({
|
||||||
email: z
|
email: z
|
||||||
@ -29,6 +34,12 @@ export function LoginForm() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { signIn, isLoading } = useAuth();
|
const { signIn, isLoading } = useAuth();
|
||||||
useRedirectIfAuthenticated();
|
useRedirectIfAuthenticated();
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading: isDataLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
} = useWorkspacePublicDataQuery();
|
||||||
|
|
||||||
const form = useForm<ILogin>({
|
const form = useForm<ILogin>({
|
||||||
validate: zodResolver(formSchema),
|
validate: zodResolver(formSchema),
|
||||||
@ -42,13 +53,25 @@ export function LoginForm() {
|
|||||||
await signIn(data);
|
await signIn(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isDataLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError && error?.["response"]?.status === 404) {
|
||||||
|
return <Error404 />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size={420} my={40} className={classes.container}>
|
<Container size={420} className={classes.container}>
|
||||||
<Box p="xl" mt={200}>
|
<Box p="xl" className={classes.containerBox}>
|
||||||
<Title order={2} ta="center" fw={500} mb="md">
|
<Title order={2} ta="center" fw={500} mb="md">
|
||||||
{t("Login")}
|
{t("Login")}
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
|
<SsoLogin />
|
||||||
|
|
||||||
|
{!data?.enforceSso && (
|
||||||
|
<>
|
||||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||||
<TextInput
|
<TextInput
|
||||||
id="email"
|
id="email"
|
||||||
@ -67,11 +90,7 @@ export function LoginForm() {
|
|||||||
{...form.getInputProps("password")}
|
{...form.getInputProps("password")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
<Group justify="flex-end" mt="sm">
|
||||||
{t("Sign In")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<Anchor
|
<Anchor
|
||||||
to={APP_ROUTE.AUTH.FORGOT_PASSWORD}
|
to={APP_ROUTE.AUTH.FORGOT_PASSWORD}
|
||||||
component={Link}
|
component={Link}
|
||||||
@ -80,6 +99,14 @@ export function LoginForm() {
|
|||||||
>
|
>
|
||||||
{t("Forgot your password?")}
|
{t("Forgot your password?")}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Button type="submit" fullWidth mt="md" loading={isLoading}>
|
||||||
|
{t("Sign In")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -37,8 +37,8 @@ export function PasswordResetForm({ resetToken }: PasswordResetFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size={420} my={40} className={classes.container}>
|
<Container size={420} className={classes.container}>
|
||||||
<Box p="xl" mt={200}>
|
<Box p="xl" className={classes.containerBox}>
|
||||||
<Title order={2} ta="center" fw={500} mb="md">
|
<Title order={2} ta="center" fw={500} mb="md">
|
||||||
{t("Password reset")}
|
{t("Password reset")}
|
||||||
</Title>
|
</Title>
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
|
|
||||||
import { useForm, zodResolver } from "@mantine/form";
|
import { useForm, zodResolver } from "@mantine/form";
|
||||||
import {
|
import {
|
||||||
Container,
|
Container,
|
||||||
@ -9,11 +8,17 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
PasswordInput,
|
PasswordInput,
|
||||||
Box,
|
Box,
|
||||||
|
Anchor,
|
||||||
|
Text,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { ISetupWorkspace } from "@/features/auth/types/auth.types";
|
import { ISetupWorkspace } from "@/features/auth/types/auth.types";
|
||||||
import useAuth from "@/features/auth/hooks/use-auth";
|
import useAuth from "@/features/auth/hooks/use-auth";
|
||||||
import classes from "@/features/auth/components/auth.module.css";
|
import classes from "@/features/auth/components/auth.module.css";
|
||||||
import { useTranslation } from "react-i18next";
|
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({
|
const formSchema = z.object({
|
||||||
workspaceName: z.string().trim().min(3).max(50),
|
workspaceName: z.string().trim().min(3).max(50),
|
||||||
@ -45,12 +50,15 @@ export function SetupWorkspaceForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size={420} my={40} className={classes.container}>
|
<div>
|
||||||
<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">
|
<Title order={2} ta="center" fw={500} mb="md">
|
||||||
{t("Create workspace")}
|
{t("Create workspace")}
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
|
{isCloud() && <SsoCloudSignup />}
|
||||||
|
|
||||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||||
<TextInput
|
<TextInput
|
||||||
id="workspaceName"
|
id="workspaceName"
|
||||||
@ -90,10 +98,23 @@ export function SetupWorkspaceForm() {
|
|||||||
{...form.getInputProps("password")}
|
{...form.getInputProps("password")}
|
||||||
/>
|
/>
|
||||||
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
||||||
{t("Setup workspace")}
|
{t("Create workspace")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,10 +19,15 @@ import {
|
|||||||
} from "@/features/auth/types/auth.types";
|
} from "@/features/auth/types/auth.types";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { IAcceptInvite } from "@/features/workspace/types/workspace.types.ts";
|
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 APP_ROUTE from "@/lib/app-route.ts";
|
||||||
import { RESET } from "jotai/utils";
|
import { RESET } from "jotai/utils";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { isCloud } from "@/lib/config.ts";
|
||||||
|
import { exchangeTokenRedirectUrl, getHostnameUrl } from "@/ee/utils.ts";
|
||||||
|
|
||||||
export default function useAuth() {
|
export default function useAuth() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -67,9 +72,21 @@ export default function useAuth() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
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);
|
const res = await setupWorkspace(data);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
navigate(APP_ROUTE.HOME);
|
navigate(APP_ROUTE.HOME);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
notifications.show({
|
notifications.show({
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||||
import { getCollabToken, verifyUserToken } from "../services/auth-service";
|
import { getCollabToken, verifyUserToken } from "../services/auth-service";
|
||||||
import { ICollabToken, IVerifyUserToken } from "../types/auth.types";
|
import { ICollabToken, IVerifyUserToken } from "../types/auth.types";
|
||||||
|
import { isAxiosError } from "axios";
|
||||||
|
|
||||||
export function useVerifyUserTokenQuery(
|
export function useVerifyUserTokenQuery(
|
||||||
verify: IVerifyUserToken,
|
verify: IVerifyUserToken,
|
||||||
@ -19,7 +20,13 @@ export function useCollabToken(): UseQueryResult<ICollabToken, Error> {
|
|||||||
queryFn: () => getCollabToken(),
|
queryFn: () => getCollabToken(),
|
||||||
staleTime: 24 * 60 * 60 * 1000, //24hrs
|
staleTime: 24 * 60 * 60 * 1000, //24hrs
|
||||||
refetchInterval: 20 * 60 * 60 * 1000, //20hrs
|
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) => {
|
retryDelay: (retryAttempt) => {
|
||||||
// Exponential backoff: 5s, 10s, 20s, etc.
|
// Exponential backoff: 5s, 10s, 20s, etc.
|
||||||
return 5000 * Math.pow(2, retryAttempt - 1);
|
return 5000 * Math.pow(2, retryAttempt - 1);
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
ISetupWorkspace,
|
ISetupWorkspace,
|
||||||
IVerifyUserToken,
|
IVerifyUserToken,
|
||||||
} from "@/features/auth/types/auth.types";
|
} from "@/features/auth/types/auth.types";
|
||||||
|
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
|
||||||
|
|
||||||
export async function login(data: ILogin): Promise<void> {
|
export async function login(data: ILogin): Promise<void> {
|
||||||
await api.post<void>("/auth/login", data);
|
await api.post<void>("/auth/login", data);
|
||||||
@ -26,8 +27,8 @@ export async function changePassword(
|
|||||||
|
|
||||||
export async function setupWorkspace(
|
export async function setupWorkspace(
|
||||||
data: ISetupWorkspace,
|
data: ISetupWorkspace,
|
||||||
): Promise<any> {
|
): Promise<IWorkspace> {
|
||||||
const req = await api.post<any>("/auth/setup", data);
|
const req = await api.post<IWorkspace>("/auth/setup", data);
|
||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -41,6 +41,7 @@ import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
|
|||||||
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
|
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
|
||||||
import DrawioMenu from "./components/drawio/drawio-menu";
|
import DrawioMenu from "./components/drawio/drawio-menu";
|
||||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||||
|
import { isCloud } from "@/lib/config.ts";
|
||||||
|
|
||||||
interface PageEditorProps {
|
interface PageEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@ -181,6 +182,7 @@ export default function PageEditor({
|
|||||||
}, [pageId]);
|
}, [pageId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isCloud()) return;
|
||||||
if (editable) {
|
if (editable) {
|
||||||
if (yjsConnectionStatus === WebSocketStatus.Connected) {
|
if (yjsConnectionStatus === WebSocketStatus.Connected) {
|
||||||
editor.setEditable(true);
|
editor.setEditable(true);
|
||||||
|
|||||||
@ -7,12 +7,22 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { formatMemberCount } from "@/lib";
|
import { formatMemberCount } from "@/lib";
|
||||||
import { IGroup } from "@/features/group/types/group.types.ts";
|
import { IGroup } from "@/features/group/types/group.types.ts";
|
||||||
import Paginate from "@/components/common/paginate.tsx";
|
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() {
|
export default function GroupList() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const { data, isLoading } = useGetGroupsQuery({ page });
|
const { data, isLoading } = useGetGroupsQuery({ page });
|
||||||
|
|
||||||
|
const prefetchGroupMembers = (groupId: string) => {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["groupMembers", groupId, { page: 1 }],
|
||||||
|
queryFn: () => getGroupMembers(groupId, { page: 1 }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Table.ScrollContainer minWidth={500}>
|
<Table.ScrollContainer minWidth={500}>
|
||||||
@ -27,7 +37,7 @@ export default function GroupList() {
|
|||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{data?.items.map((group: IGroup, index: number) => (
|
{data?.items.map((group: IGroup, index: number) => (
|
||||||
<Table.Tr key={index}>
|
<Table.Tr key={index}>
|
||||||
<Table.Td>
|
<Table.Td onMouseEnter={() => prefetchGroupMembers(group.id)}>
|
||||||
<Anchor
|
<Anchor
|
||||||
size="sm"
|
size="sm"
|
||||||
underline="never"
|
underline="never"
|
||||||
|
|||||||
@ -19,15 +19,30 @@ import {
|
|||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||||
import { IUser } from "@/features/user/types/user.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(
|
export function useGetGroupsQuery(
|
||||||
params?: QueryParams,
|
params?: QueryParams,
|
||||||
): UseQueryResult<IPagination<IGroup>, Error> {
|
): UseQueryResult<IPagination<IGroup>, Error> {
|
||||||
return useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["groups", params],
|
queryKey: ["groups", params],
|
||||||
queryFn: () => getGroups(params),
|
queryFn: () => getGroups(params),
|
||||||
placeholderData: keepPreviousData,
|
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> {
|
export function useGroupQuery(groupId: string): UseQueryResult<IGroup, Error> {
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import { Text, Avatar, SimpleGrid, Card, rem } from "@mantine/core";
|
import { Text, Avatar, SimpleGrid, Card, rem } from "@mantine/core";
|
||||||
import React from "react";
|
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 { getSpaceUrl } from "@/lib/config.ts";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import classes from "./space-grid.module.css";
|
import classes from "./space-grid.module.css";
|
||||||
@ -18,6 +21,7 @@ export default function SpaceGrid() {
|
|||||||
radius="md"
|
radius="md"
|
||||||
component={Link}
|
component={Link}
|
||||||
to={getSpaceUrl(space.slug)}
|
to={getSpaceUrl(space.slug)}
|
||||||
|
onMouseEnter={() => prefetchSpace(space.slug, space.id)}
|
||||||
className={classes.card}
|
className={classes.card}
|
||||||
withBorder
|
withBorder
|
||||||
>
|
>
|
||||||
|
|||||||
@ -25,6 +25,10 @@ import {
|
|||||||
} from "@/features/space/services/space-service.ts";
|
} from "@/features/space/services/space-service.ts";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
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(
|
export function useGetSpacesQuery(
|
||||||
params?: QueryParams,
|
params?: QueryParams,
|
||||||
@ -37,14 +41,39 @@ export function useGetSpacesQuery(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
|
export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
|
||||||
return useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["space", spaceId],
|
queryKey: ["space", spaceId],
|
||||||
queryFn: () => getSpaceById(spaceId),
|
queryFn: () => getSpaceById(spaceId),
|
||||||
enabled: !!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() {
|
export function useCreateSpaceMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|||||||
@ -9,16 +9,21 @@ import { SOCKET_URL } from "@/features/websocket/types";
|
|||||||
import { useQuerySubscription } from "@/features/websocket/use-query-subscription.ts";
|
import { useQuerySubscription } from "@/features/websocket/use-query-subscription.ts";
|
||||||
import { useTreeSocket } from "@/features/websocket/use-tree-socket.ts";
|
import { useTreeSocket } from "@/features/websocket/use-tree-socket.ts";
|
||||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||||
|
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||||
|
|
||||||
export function UserProvider({ children }: React.PropsWithChildren) {
|
export function UserProvider({ children }: React.PropsWithChildren) {
|
||||||
const [, setCurrentUser] = useAtom(currentUserAtom);
|
const [, setCurrentUser] = useAtom(currentUserAtom);
|
||||||
const { data, isLoading, error } = useCurrentUser();
|
const { data, isLoading, error, isError } = useCurrentUser();
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
const [, setSocket] = useAtom(socketAtom);
|
const [, setSocket] = useAtom(socketAtom);
|
||||||
// fetch collab token on load
|
// fetch collab token on load
|
||||||
const { data: collab } = useCollabToken();
|
const { data: collab } = useCollabToken();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isLoading || isError) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const newSocket = io(SOCKET_URL, {
|
const newSocket = io(SOCKET_URL, {
|
||||||
transports: ["websocket"],
|
transports: ["websocket"],
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
@ -35,7 +40,7 @@ export function UserProvider({ children }: React.PropsWithChildren) {
|
|||||||
console.log("ws disconnected");
|
console.log("ws disconnected");
|
||||||
newSocket.disconnect();
|
newSocket.disconnect();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [isError, isLoading]);
|
||||||
|
|
||||||
useQuerySubscription();
|
useQuerySubscription();
|
||||||
useTreeSocket();
|
useTreeSocket();
|
||||||
@ -51,10 +56,12 @@ export function UserProvider({ children }: React.PropsWithChildren) {
|
|||||||
|
|
||||||
if (isLoading) return <></>;
|
if (isLoading) return <></>;
|
||||||
|
|
||||||
if (!data?.user && !data?.workspace) return <></>;
|
if (isError && error?.["response"]?.status === 404) {
|
||||||
|
return <Error404 />;
|
||||||
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <>an error occurred</>;
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|||||||
@ -1,4 +1 @@
|
|||||||
export const SOCKET_URL = import.meta.env.DEV
|
export const SOCKET_URL = undefined
|
||||||
? process.env.APP_URL
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
@ -11,6 +11,7 @@ import { notifications } from "@mantine/notifications";
|
|||||||
import { useClipboard } from "@mantine/hooks";
|
import { useClipboard } from "@mantine/hooks";
|
||||||
import { getInviteLink } from "@/features/workspace/services/workspace-service.ts";
|
import { getInviteLink } from "@/features/workspace/services/workspace-service.ts";
|
||||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
|
import { isCloud } from "@/lib/config.ts";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
invitationId: string;
|
invitationId: string;
|
||||||
@ -76,6 +77,7 @@ export default function InviteActionMenu({ invitationId }: Props) {
|
|||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
|
{!isCloud() && (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
onClick={() => handleCopyLink(invitationId)}
|
onClick={() => handleCopyLink(invitationId)}
|
||||||
leftSection={<IconCopy size={16} />}
|
leftSection={<IconCopy size={16} />}
|
||||||
@ -83,6 +85,8 @@ export default function InviteActionMenu({ invitationId }: Props) {
|
|||||||
>
|
>
|
||||||
{t("Copy link")}
|
{t("Copy link")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
onClick={onResend}
|
onClick={onResend}
|
||||||
leftSection={<IconSend size={16} />}
|
leftSection={<IconSend size={16} />}
|
||||||
|
|||||||
@ -9,12 +9,13 @@ export default function WorkspaceInviteSection() {
|
|||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const [inviteLink, setInviteLink] = useState<string>("");
|
const [inviteLink, setInviteLink] = useState<string>("");
|
||||||
|
|
||||||
|
/*
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setInviteLink(
|
setInviteLink(
|
||||||
`${window.location.origin}/invite/${currentUser.workspace.inviteCode}`,
|
`${window.location.origin}/invite/${currentUser.workspace.inviteCode}`,
|
||||||
);
|
);
|
||||||
}, [currentUser.workspace.inviteCode]);
|
}, [currentUser.workspace.inviteCode]);
|
||||||
|
*/
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -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 { useAtom } from "jotai";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { focusAtom } from "jotai-optics";
|
|
||||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||||
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
|
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
|
||||||
import { TextInput, Button } from "@mantine/core";
|
import { TextInput, Button } from "@mantine/core";
|
||||||
@ -17,21 +16,16 @@ const formSchema = z.object({
|
|||||||
|
|
||||||
type FormValues = z.infer<typeof formSchema>;
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
const workspaceAtom = focusAtom(currentUserAtom, (optic) =>
|
|
||||||
optic.prop("workspace"),
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function WorkspaceNameForm() {
|
export default function WorkspaceNameForm() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||||
const [, setWorkspace] = useAtom(workspaceAtom);
|
|
||||||
const { isAdmin } = useUserRole();
|
const { isAdmin } = useUserRole();
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
validate: zodResolver(formSchema),
|
validate: zodResolver(formSchema),
|
||||||
initialValues: {
|
initialValues: {
|
||||||
name: currentUser?.workspace?.name,
|
name: workspace?.name,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -39,7 +33,7 @@ export default function WorkspaceNameForm() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updatedWorkspace = await updateWorkspace(data);
|
const updatedWorkspace = await updateWorkspace({ name: data.name });
|
||||||
setWorkspace(updatedWorkspace);
|
setWorkspace(updatedWorkspace);
|
||||||
notifications.show({ message: t("Updated successfully") });
|
notifications.show({ message: t("Updated successfully") });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import { notifications } from "@mantine/notifications";
|
|||||||
import {
|
import {
|
||||||
ICreateInvite,
|
ICreateInvite,
|
||||||
IInvitation,
|
IInvitation,
|
||||||
|
IPublicWorkspace,
|
||||||
IWorkspace,
|
IWorkspace,
|
||||||
} from "@/features/workspace/types/workspace.types.ts";
|
} from "@/features/workspace/types/workspace.types.ts";
|
||||||
import { IUser } from "@/features/user/types/user.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<
|
export function useWorkspacePublicDataQuery(): UseQueryResult<
|
||||||
IWorkspace,
|
IPublicWorkspace,
|
||||||
Error
|
Error
|
||||||
> {
|
> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|||||||
@ -5,17 +5,26 @@ import {
|
|||||||
IInvitation,
|
IInvitation,
|
||||||
IWorkspace,
|
IWorkspace,
|
||||||
IAcceptInvite,
|
IAcceptInvite,
|
||||||
|
IPublicWorkspace,
|
||||||
IInvitationLink,
|
IInvitationLink,
|
||||||
} from "../types/workspace.types";
|
} from "../types/workspace.types";
|
||||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||||
|
import { ISetupWorkspace } from "@/features/auth/types/auth.types.ts";
|
||||||
|
|
||||||
export async function getWorkspace(): Promise<IWorkspace> {
|
export async function getWorkspace(): Promise<IWorkspace> {
|
||||||
const req = await api.post<IWorkspace>("/workspace/info");
|
const req = await api.post<IWorkspace>("/workspace/info");
|
||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getWorkspacePublicData(): Promise<IWorkspace> {
|
export async function getWorkspacePublicData(): Promise<IPublicWorkspace> {
|
||||||
const req = await api.post<IWorkspace>("/workspace/public");
|
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;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,6 +90,13 @@ export async function getInvitationById(data: {
|
|||||||
return req.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) {
|
export async function uploadLogo(file: File) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("type", "workspace-logo");
|
formData.append("type", "workspace-logo");
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
||||||
|
|
||||||
export interface IWorkspace {
|
export interface IWorkspace {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@ -7,10 +9,17 @@ export interface IWorkspace {
|
|||||||
defaultSpaceId: string;
|
defaultSpaceId: string;
|
||||||
customDomain: string;
|
customDomain: string;
|
||||||
enableInvite: boolean;
|
enableInvite: boolean;
|
||||||
inviteCode: string;
|
|
||||||
settings: any;
|
settings: any;
|
||||||
|
status: string;
|
||||||
|
enforceSso: boolean;
|
||||||
|
billingEmail: string;
|
||||||
|
trialEndAt: Date;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
emailDomains: string[];
|
||||||
|
memberCount?: number;
|
||||||
|
plan?: string;
|
||||||
|
hasLicenseKey?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ICreateInvite {
|
export interface ICreateInvite {
|
||||||
@ -38,3 +47,13 @@ export interface IAcceptInvite {
|
|||||||
password: string;
|
password: string;
|
||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IPublicWorkspace {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
logo: string;
|
||||||
|
hostname: string;
|
||||||
|
enforceSso: boolean;
|
||||||
|
authProviders: IAuthProvider[];
|
||||||
|
hasLicenseKey?: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import axios, { AxiosInstance } from "axios";
|
import axios, { AxiosInstance } from "axios";
|
||||||
import APP_ROUTE from "@/lib/app-route.ts";
|
import APP_ROUTE from "@/lib/app-route.ts";
|
||||||
|
import { isCloud } from "@/lib/config.ts";
|
||||||
|
|
||||||
const api: AxiosInstance = axios.create({
|
const api: AxiosInstance = axios.create({
|
||||||
baseURL: "/api",
|
baseURL: "/api",
|
||||||
@ -41,7 +42,10 @@ api.interceptors.response.use(
|
|||||||
.includes("workspace not found")
|
.includes("workspace not found")
|
||||||
) {
|
) {
|
||||||
console.log("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;
|
window.location.href = APP_ROUTE.AUTH.SETUP;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,8 @@ const APP_ROUTE = {
|
|||||||
SETUP: "/setup/register",
|
SETUP: "/setup/register",
|
||||||
FORGOT_PASSWORD: "/forgot-password",
|
FORGOT_PASSWORD: "/forgot-password",
|
||||||
PASSWORD_RESET: "/password-reset",
|
PASSWORD_RESET: "/password-reset",
|
||||||
|
CREATE_WORKSPACE: "/create",
|
||||||
|
SELECT_WORKSPACE: "/select",
|
||||||
},
|
},
|
||||||
SETTINGS: {
|
SETTINGS: {
|
||||||
ACCOUNT: {
|
ACCOUNT: {
|
||||||
@ -17,6 +19,8 @@ const APP_ROUTE = {
|
|||||||
MEMBERS: "/settings/members",
|
MEMBERS: "/settings/members",
|
||||||
GROUPS: "/settings/groups",
|
GROUPS: "/settings/groups",
|
||||||
SPACES: "/settings/spaces",
|
SPACES: "/settings/spaces",
|
||||||
|
BILLING: "/settings/billing",
|
||||||
|
SECURITY: "/settings/security",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import bytes from "bytes";
|
import bytes from "bytes";
|
||||||
|
import { castToBoolean } from "@/lib/utils.tsx";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@ -14,6 +15,10 @@ export function getAppUrl(): string {
|
|||||||
return `${window.location.protocol}//${window.location.host}`;
|
return `${window.location.protocol}//${window.location.host}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getServerAppUrl(): string {
|
||||||
|
return getConfigValue("APP_URL");
|
||||||
|
}
|
||||||
|
|
||||||
export function getBackendUrl(): string {
|
export function getBackendUrl(): string {
|
||||||
return getAppUrl() + "/api";
|
return getAppUrl() + "/api";
|
||||||
}
|
}
|
||||||
@ -28,6 +33,14 @@ export function getCollaborationUrl(): string {
|
|||||||
return collabUrl.toString();
|
return collabUrl.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSubdomainHost(): string {
|
||||||
|
return getConfigValue("SUBDOMAIN_HOST");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCloud(): boolean {
|
||||||
|
return castToBoolean(getConfigValue("CLOUD"));
|
||||||
|
}
|
||||||
|
|
||||||
export function getAvatarUrl(avatarUrl: string) {
|
export function getAvatarUrl(avatarUrl: string) {
|
||||||
if (!avatarUrl) return null;
|
if (!avatarUrl) return null;
|
||||||
if (avatarUrl?.startsWith("http")) return avatarUrl;
|
if (avatarUrl?.startsWith("http")) return avatarUrl;
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import "@mantine/spotlight/styles.css";
|
|||||||
import "@mantine/notifications/styles.css";
|
import "@mantine/notifications/styles.css";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
import { theme } from "@/theme";
|
import { mantineCssResolver, theme } from '@/theme';
|
||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import { ModalsProvider } from "@mantine/modals";
|
import { ModalsProvider } from "@mantine/modals";
|
||||||
@ -18,6 +18,7 @@ export const queryClient = new QueryClient({
|
|||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
retry: false,
|
retry: false,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -29,7 +30,7 @@ const root = ReactDOM.createRoot(
|
|||||||
|
|
||||||
root.render(
|
root.render(
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<MantineProvider theme={theme}>
|
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
||||||
<ModalsProvider>
|
<ModalsProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<Notifications position="bottom-center" limit={3} />
|
<Notifications position="bottom-center" limit={3} />
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { LoginForm } from "@/features/auth/components/login-form";
|
import { LoginForm } from "@/features/auth/components/login-form";
|
||||||
import { Helmet } from "react-helmet-async";
|
import { Helmet } from "react-helmet-async";
|
||||||
import {getAppName} from "@/lib/config.ts";
|
import { getAppName } from "@/lib/config.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
@ -9,7 +9,9 @@ export default function LoginPage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{t("Login")} - {getAppName()}</title>
|
<title>
|
||||||
|
{t("Login")} - {getAppName()}
|
||||||
|
</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<LoginForm />
|
<LoginForm />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -3,7 +3,8 @@ import { SetupWorkspaceForm } from "@/features/auth/components/setup-workspace-f
|
|||||||
import { Helmet } from "react-helmet-async";
|
import { Helmet } from "react-helmet-async";
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
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";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function SetupWorkspace() {
|
export default function SetupWorkspace() {
|
||||||
@ -18,10 +19,10 @@ export default function SetupWorkspace() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && !isError && workspace) {
|
if (!isLoading && workspace) {
|
||||||
navigate("/");
|
navigate(APP_ROUTE.AUTH.LOGIN);
|
||||||
}
|
}
|
||||||
}, [isLoading, isError, workspace]);
|
}, [isLoading, workspace]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div></div>;
|
return <div></div>;
|
||||||
@ -35,7 +36,9 @@ export default function SetupWorkspace() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{t("Setup Workspace")} - {getAppName()}</title>
|
<title>
|
||||||
|
{t("Setup Workspace")} - {getAppName()}
|
||||||
|
</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<SetupWorkspaceForm />
|
<SetupWorkspaceForm />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -1,21 +1,26 @@
|
|||||||
import {Container, Space} from "@mantine/core";
|
import { Container, Space } from "@mantine/core";
|
||||||
import HomeTabs from "@/features/home/components/home-tabs";
|
import HomeTabs from "@/features/home/components/home-tabs";
|
||||||
import SpaceGrid from "@/features/space/components/space-grid.tsx";
|
import SpaceGrid from "@/features/space/components/space-grid.tsx";
|
||||||
import {getAppName} from "@/lib/config.ts";
|
import { getAppName } from "@/lib/config.ts";
|
||||||
import {Helmet} from "react-helmet-async";
|
import { Helmet } from "react-helmet-async";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>Home - {getAppName()}</title>
|
<title>
|
||||||
|
{t("Home")} - {getAppName()}
|
||||||
|
</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<Container size={"800"} pt="xl">
|
<Container size={"800"} pt="xl">
|
||||||
<SpaceGrid/>
|
<SpaceGrid />
|
||||||
|
|
||||||
<Space h="xl"/>
|
<Space h="xl" />
|
||||||
|
|
||||||
<HomeTabs/>
|
<HomeTabs />
|
||||||
</Container>
|
</Container>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,21 +1,24 @@
|
|||||||
import WorkspaceInviteModal from "@/features/workspace/components/members/components/workspace-invite-modal";
|
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 WorkspaceMembersTable from "@/features/workspace/components/members/components/workspace-members-table";
|
||||||
import SettingsTitle from "@/components/settings/settings-title.tsx";
|
import SettingsTitle from "@/components/settings/settings-title.tsx";
|
||||||
import {useEffect, useState} from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {useNavigate, useSearchParams} from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import WorkspaceInvitesTable from "@/features/workspace/components/members/components/workspace-invites-table.tsx";
|
import WorkspaceInvitesTable from "@/features/workspace/components/members/components/workspace-invites-table.tsx";
|
||||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
import {getAppName} from "@/lib/config.ts";
|
import { getAppName } from "@/lib/config.ts";
|
||||||
import {Helmet} from "react-helmet-async";
|
import { Helmet } from "react-helmet-async";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
|
|
||||||
export default function WorkspaceMembers() {
|
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(() => {
|
useEffect(() => {
|
||||||
const currentTab = searchParams.get("tab");
|
const currentTab = searchParams.get("tab");
|
||||||
@ -36,9 +39,11 @@ export default function WorkspaceMembers() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{t("Members")} - {getAppName()}</title>
|
<title>
|
||||||
|
{t("Members")} - {getAppName()}
|
||||||
|
</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<SettingsTitle title={t("Members")}/>
|
<SettingsTitle title={t("Members")} />
|
||||||
|
|
||||||
{/* <WorkspaceInviteSection /> */}
|
{/* <WorkspaceInviteSection /> */}
|
||||||
{/* <Divider my="lg" /> */}
|
{/* <Divider my="lg" /> */}
|
||||||
@ -48,21 +53,24 @@ export default function WorkspaceMembers() {
|
|||||||
value={segmentValue}
|
value={segmentValue}
|
||||||
onChange={handleSegmentChange}
|
onChange={handleSegmentChange}
|
||||||
data={[
|
data={[
|
||||||
{ label: t("Members"), value: "members" },
|
{
|
||||||
|
label: t("Members") + ` (${workspace?.memberCount})`,
|
||||||
|
value: "members",
|
||||||
|
},
|
||||||
{ label: t("Pending"), value: "invites" },
|
{ label: t("Pending"), value: "invites" },
|
||||||
]}
|
]}
|
||||||
withItemsBorders={false}
|
withItemsBorders={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isAdmin && <WorkspaceInviteModal/>}
|
{isAdmin && <WorkspaceInviteModal />}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Space h="lg"/>
|
<Space h="lg" />
|
||||||
|
|
||||||
{segmentValue === "invites" ? (
|
{segmentValue === "invites" ? (
|
||||||
<WorkspaceInvitesTable/>
|
<WorkspaceInvitesTable />
|
||||||
) : (
|
) : (
|
||||||
<WorkspaceMembersTable/>
|
<WorkspaceMembersTable />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import SettingsTitle from "@/components/settings/settings-title.tsx";
|
import SettingsTitle from "@/components/settings/settings-title.tsx";
|
||||||
import WorkspaceNameForm from "@/features/workspace/components/settings/components/workspace-name-form";
|
import WorkspaceNameForm from "@/features/workspace/components/settings/components/workspace-name-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {getAppName} from "@/lib/config.ts";
|
import { getAppName, isCloud } from "@/lib/config.ts";
|
||||||
import {Helmet} from "react-helmet-async";
|
import { Helmet } from "react-helmet-async";
|
||||||
|
import ManageHostname from "@/ee/components/manage-hostname.tsx";
|
||||||
|
import { Divider } from "@mantine/core";
|
||||||
|
|
||||||
export default function WorkspaceSettings() {
|
export default function WorkspaceSettings() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -12,7 +14,14 @@ export default function WorkspaceSettings() {
|
|||||||
<title>Workspace Settings - {getAppName()}</title>
|
<title>Workspace Settings - {getAppName()}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<SettingsTitle title={t("General")} />
|
<SettingsTitle title={t("General")} />
|
||||||
<WorkspaceNameForm/>
|
<WorkspaceNameForm />
|
||||||
|
|
||||||
|
{isCloud() && (
|
||||||
|
<>
|
||||||
|
<Divider my="md" />
|
||||||
|
<ManageHostname />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,29 +1,33 @@
|
|||||||
import { createTheme, MantineColorsTuple } from '@mantine/core';
|
import {
|
||||||
|
createTheme,
|
||||||
|
CSSVariablesResolver,
|
||||||
|
MantineColorsTuple,
|
||||||
|
} from "@mantine/core";
|
||||||
|
|
||||||
const blue: MantineColorsTuple = [
|
const blue: MantineColorsTuple = [
|
||||||
'#e7f3ff',
|
"#e7f3ff",
|
||||||
'#d0e4ff',
|
"#d0e4ff",
|
||||||
'#a1c6fa',
|
"#a1c6fa",
|
||||||
'#6ea6f6',
|
"#6ea6f6",
|
||||||
'#458bf2',
|
"#458bf2",
|
||||||
'#2b7af1',
|
"#2b7af1",
|
||||||
'#0b60d8',
|
"#0b60d8",
|
||||||
'#1b72f2',
|
"#1b72f2",
|
||||||
'#0056c1',
|
"#0056c1",
|
||||||
'#004aac',
|
"#004aac",
|
||||||
];
|
];
|
||||||
|
|
||||||
const red: MantineColorsTuple = [
|
const red: MantineColorsTuple = [
|
||||||
'#ffebeb',
|
"#ffebeb",
|
||||||
'#fad7d7',
|
"#fad7d7",
|
||||||
'#eeadad',
|
"#eeadad",
|
||||||
'#e3807f',
|
"#e3807f",
|
||||||
'#da5a59',
|
"#da5a59",
|
||||||
'#d54241',
|
"#d54241",
|
||||||
'#d43535',
|
"#d43535",
|
||||||
'#bc2727',
|
"#bc2727",
|
||||||
'#a82022',
|
"#a82022",
|
||||||
'#93151b',
|
"#93151b",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const theme = createTheme({
|
export const theme = createTheme({
|
||||||
@ -32,3 +36,11 @@ export const theme = createTheme({
|
|||||||
red,
|
red,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const mantineCssResolver: CSSVariablesResolver = (theme) => ({
|
||||||
|
variables: {
|
||||||
|
"--input-error-size": theme.fontSizes.sm,
|
||||||
|
},
|
||||||
|
light: {},
|
||||||
|
dark: {},
|
||||||
|
});
|
||||||
|
|||||||
@ -5,11 +5,14 @@ import * as path from "path";
|
|||||||
export const envPath = path.resolve(process.cwd(), "..", "..");
|
export const envPath = path.resolve(process.cwd(), "..", "..");
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
const { APP_URL, FILE_UPLOAD_SIZE_LIMIT, DRAWIO_URL, COLLAB_URL } = loadEnv(
|
const {
|
||||||
mode,
|
APP_URL,
|
||||||
envPath,
|
FILE_UPLOAD_SIZE_LIMIT,
|
||||||
"",
|
DRAWIO_URL,
|
||||||
);
|
CLOUD,
|
||||||
|
SUBDOMAIN_HOST,
|
||||||
|
COLLAB_URL,
|
||||||
|
} = loadEnv(mode, envPath, "");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
define: {
|
define: {
|
||||||
@ -17,6 +20,8 @@ export default defineConfig(({ mode }) => {
|
|||||||
APP_URL,
|
APP_URL,
|
||||||
FILE_UPLOAD_SIZE_LIMIT,
|
FILE_UPLOAD_SIZE_LIMIT,
|
||||||
DRAWIO_URL,
|
DRAWIO_URL,
|
||||||
|
CLOUD,
|
||||||
|
SUBDOMAIN_HOST,
|
||||||
COLLAB_URL,
|
COLLAB_URL,
|
||||||
},
|
},
|
||||||
APP_VERSION: JSON.stringify(process.env.npm_package_version),
|
APP_VERSION: JSON.stringify(process.env.npm_package_version),
|
||||||
@ -31,7 +36,17 @@ export default defineConfig(({ mode }) => {
|
|||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
target: APP_URL,
|
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
Reference in New Issue
Block a user