mirror of
https://github.com/docmost/docmost.git
synced 2025-11-14 05:31:11 +10:00
Merge branch 'main' into feat/resolve-comment
This commit is contained in:
@ -16,12 +16,12 @@
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@excalidraw/excalidraw": "0.18.0-864353b",
|
||||
"@mantine/core": "^7.17.0",
|
||||
"@mantine/form": "^7.17.0",
|
||||
"@mantine/hooks": "^7.17.0",
|
||||
"@mantine/modals": "^7.17.0",
|
||||
"@mantine/notifications": "^7.17.0",
|
||||
"@mantine/spotlight": "^7.17.0",
|
||||
"@mantine/core": "^8.1.3",
|
||||
"@mantine/form": "^8.1.3",
|
||||
"@mantine/hooks": "^8.1.3",
|
||||
"@mantine/modals": "^8.1.3",
|
||||
"@mantine/notifications": "^8.1.3",
|
||||
"@mantine/spotlight": "^8.1.3",
|
||||
"@tabler/icons-react": "^3.34.0",
|
||||
"@tanstack/react-query": "^5.80.6",
|
||||
"@tiptap/extension-character-count": "^2.10.3",
|
||||
@ -39,6 +39,7 @@
|
||||
"jwt-decode": "^4.0.0",
|
||||
"katex": "0.16.22",
|
||||
"lowlight": "^3.3.0",
|
||||
"mantine-form-zod-resolver": "^1.3.0",
|
||||
"mermaid": "^11.6.0",
|
||||
"mitt": "^3.0.1",
|
||||
"posthog-js": "^1.255.1",
|
||||
|
||||
@ -233,7 +233,9 @@
|
||||
"Anyone with this link can join this workspace.": "Anyone with this link can join this workspace.",
|
||||
"Invite link": "Invite link",
|
||||
"Copy": "Copy",
|
||||
"Copy to space": "Copy to space",
|
||||
"Copied": "Copied",
|
||||
"Duplicate": "Duplicate",
|
||||
"Select a user": "Select a user",
|
||||
"Select a group": "Select a group",
|
||||
"Export all pages and attachments in this space.": "Export all pages and attachments in this space.",
|
||||
@ -400,5 +402,83 @@
|
||||
"Failed to share page": "Failed to share page",
|
||||
"Copy page": "Copy page",
|
||||
"Copy page to a different space.": "Copy page to a different space.",
|
||||
"Page copied successfully": "Page copied successfully"
|
||||
"Page copied successfully": "Page copied successfully",
|
||||
"Page duplicated successfully": "Page duplicated successfully",
|
||||
"Find": "Find",
|
||||
"Not found": "Not found",
|
||||
"Previous Match (Shift+Enter)": "Previous Match (Shift+Enter)",
|
||||
"Next match (Enter)": "Next match (Enter)",
|
||||
"Match case (Alt+C)": "Match case (Alt+C)",
|
||||
"Replace": "Replace",
|
||||
"Close (Escape)": "Close (Escape)",
|
||||
"Replace (Enter)": "Replace (Enter)",
|
||||
"Replace all (Ctrl+Alt+Enter)": "Replace all (Ctrl+Alt+Enter)",
|
||||
"Replace all": "Replace all",
|
||||
"View all spaces": "View all spaces"
|
||||
"Error": "Error",
|
||||
"Failed to disable MFA": "Failed to disable MFA",
|
||||
"Disable two-factor authentication": "Disable two-factor authentication",
|
||||
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.",
|
||||
"Please enter your password to disable two-factor authentication:": "Please enter your password to disable two-factor authentication:",
|
||||
"Two-factor authentication has been enabled": "Two-factor authentication has been enabled",
|
||||
"Two-factor authentication has been disabled": "Two-factor authentication has been disabled",
|
||||
"2-step verification": "2-step verification",
|
||||
"Protect your account with an additional verification layer when signing in.": "Protect your account with an additional verification layer when signing in.",
|
||||
"Two-factor authentication is active on your account.": "Two-factor authentication is active on your account.",
|
||||
"Add 2FA method": "Add 2FA method",
|
||||
"Backup codes": "Backup codes",
|
||||
"Disable": "Disable",
|
||||
"Invalid verification code": "Invalid verification code",
|
||||
"New backup codes have been generated": "New backup codes have been generated",
|
||||
"Failed to regenerate backup codes": "Failed to regenerate backup codes",
|
||||
"About backup codes": "About backup codes",
|
||||
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.",
|
||||
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "You can regenerate new backup codes at any time. This will invalidate all existing codes.",
|
||||
"Confirm password": "Confirm password",
|
||||
"Generate new backup codes": "Generate new backup codes",
|
||||
"Save your new backup codes": "Save your new backup codes",
|
||||
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "Make sure to save these codes in a secure place. Your old backup codes are no longer valid.",
|
||||
"Your new backup codes": "Your new backup codes",
|
||||
"I've saved my backup codes": "I've saved my backup codes",
|
||||
"Failed to setup MFA": "Failed to setup MFA",
|
||||
"Setup & Verify": "Setup & Verify",
|
||||
"Add to authenticator": "Add to authenticator",
|
||||
"1. Scan this QR code with your authenticator app": "1. Scan this QR code with your authenticator app",
|
||||
"Can't scan the code?": "Can't scan the code?",
|
||||
"Enter this code manually in your authenticator app:": "Enter this code manually in your authenticator app:",
|
||||
"2. Enter the 6-digit code from your authenticator": "2. Enter the 6-digit code from your authenticator",
|
||||
"Verify and enable": "Verify and enable",
|
||||
"Failed to generate QR code. Please try again.": "Failed to generate QR code. Please try again.",
|
||||
"Backup": "Backup",
|
||||
"Save codes": "Save codes",
|
||||
"Save your backup codes": "Save your backup codes",
|
||||
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.",
|
||||
"Print": "Print",
|
||||
"Two-factor authentication has been set up. Please log in again.": "Two-factor authentication has been set up. Please log in again.",
|
||||
"Two-Factor authentication required": "Two-factor authentication required",
|
||||
"Your workspace requires two-factor authentication for all users": "Your workspace requires two-factor authentication for all users",
|
||||
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.",
|
||||
"Set up two-factor authentication": "Set up two-factor authentication",
|
||||
"Cancel and logout": "Cancel and logout",
|
||||
"Your workspace requires two-factor authentication. Please set it up to continue.": "Your workspace requires two-factor authentication. Please set it up to continue.",
|
||||
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "This adds an extra layer of security to your account by requiring a verification code from your authenticator app.",
|
||||
"Password is required": "Password is required",
|
||||
"Password must be at least 8 characters": "Password must be at least 8 characters",
|
||||
"Please enter a 6-digit code": "Please enter a 6-digit code",
|
||||
"Code must be exactly 6 digits": "Code must be exactly 6 digits",
|
||||
"Enter the 6-digit code found in your authenticator app": "Enter the 6-digit code found in your authenticator app",
|
||||
"Need help authenticating?": "Need help authenticating?",
|
||||
"MFA QR Code": "MFA QR Code",
|
||||
"Account created successfully. Please log in to set up two-factor authentication.": "Account created successfully. Please log in to set up two-factor authentication.",
|
||||
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "Password reset successful. Please log in with your new password and complete two-factor authentication.",
|
||||
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "Password reset successful. Please log in with your new password to set up two-factor authentication.",
|
||||
"Password reset was successful. Please log in with your new password.": "Password reset was successful. Please log in with your new password.",
|
||||
"Two-factor authentication": "Two-factor authentication",
|
||||
"Use authenticator app instead": "Use authenticator app instead",
|
||||
"Verify backup code": "Verify backup code",
|
||||
"Use backup code": "Use backup code",
|
||||
"Enter one of your backup codes": "Enter one of your backup codes",
|
||||
"Backup code": "Backup code",
|
||||
"Enter one of your backup codes. Each backup code can only be used once.": "Enter one of your backup codes. Each backup code can only be used once.",
|
||||
"Verify": "Verify"
|
||||
}
|
||||
|
||||
@ -29,8 +29,11 @@ import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-selec
|
||||
import SharedPage from "@/pages/share/shared-page.tsx";
|
||||
import Shares from "@/pages/settings/shares/shares.tsx";
|
||||
import ShareLayout from "@/features/share/components/share-layout.tsx";
|
||||
import ShareRedirect from '@/pages/share/share-redirect.tsx';
|
||||
import ShareRedirect from "@/pages/share/share-redirect.tsx";
|
||||
import { useTrackOrigin } from "@/hooks/use-track-origin";
|
||||
import SpacesPage from "@/pages/spaces/spaces.tsx";
|
||||
import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
|
||||
import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
|
||||
|
||||
export default function App() {
|
||||
const { t } = useTranslation();
|
||||
@ -45,6 +48,11 @@ export default function App() {
|
||||
<Route path={"/invites/:invitationId"} element={<InviteSignup />} />
|
||||
<Route path={"/forgot-password"} element={<ForgotPassword />} />
|
||||
<Route path={"/password-reset"} element={<PasswordReset />} />
|
||||
<Route path={"/login/mfa"} element={<MfaChallengePage />} />
|
||||
<Route
|
||||
path={"/login/mfa/setup"}
|
||||
element={<MfaSetupRequiredPage />}
|
||||
/>
|
||||
|
||||
{!isCloud() && (
|
||||
<Route path={"/setup/register"} element={<SetupWorkspace />} />
|
||||
@ -58,7 +66,10 @@ export default function App() {
|
||||
)}
|
||||
|
||||
<Route element={<ShareLayout />}>
|
||||
<Route path={"/share/:shareId/p/:pageSlug"} element={<SharedPage />} />
|
||||
<Route
|
||||
path={"/share/:shareId/p/:pageSlug"}
|
||||
element={<SharedPage />}
|
||||
/>
|
||||
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
|
||||
</Route>
|
||||
|
||||
@ -67,6 +78,7 @@ export default function App() {
|
||||
|
||||
<Route element={<Layout />}>
|
||||
<Route path={"/home"} element={<Home />} />
|
||||
<Route path={"/spaces"} element={<SpacesPage />} />
|
||||
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
|
||||
<Route
|
||||
path={"/s/:spaceSlug/p/:pageSlug"}
|
||||
|
||||
@ -27,6 +27,8 @@ export function AppHeader() {
|
||||
const { isTrial, trialDaysLeft } = useTrial();
|
||||
|
||||
const isHomeRoute = location.pathname.startsWith("/home");
|
||||
const isSpacesRoute = location.pathname === "/spaces";
|
||||
const hideSidebar = isHomeRoute || isSpacesRoute;
|
||||
|
||||
const items = links.map((link) => (
|
||||
<Link key={link.label} to={link.link} className={classes.link}>
|
||||
@ -38,7 +40,7 @@ export function AppHeader() {
|
||||
<>
|
||||
<Group h="100%" px="md" justify="space-between" wrap={"nowrap"}>
|
||||
<Group wrap="nowrap">
|
||||
{!isHomeRoute && (
|
||||
{!hideSidebar && (
|
||||
<>
|
||||
<Tooltip label={t("Sidebar toggle")}>
|
||||
<SidebarToggle
|
||||
|
||||
@ -73,13 +73,15 @@ export default function GlobalAppShell({
|
||||
const isSettingsRoute = location.pathname.startsWith("/settings");
|
||||
const isSpaceRoute = location.pathname.startsWith("/s/");
|
||||
const isHomeRoute = location.pathname.startsWith("/home");
|
||||
const isSpacesRoute = location.pathname === "/spaces";
|
||||
const isPageRoute = location.pathname.includes("/p/");
|
||||
const hideSidebar = isHomeRoute || isSpacesRoute;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={{ height: 45 }}
|
||||
navbar={
|
||||
!isHomeRoute && {
|
||||
!hideSidebar && {
|
||||
width: isSpaceRoute ? sidebarWidth : 300,
|
||||
breakpoint: "sm",
|
||||
collapsed: {
|
||||
@ -100,7 +102,7 @@ export default function GlobalAppShell({
|
||||
<AppShell.Header px="md" className={classes.header}>
|
||||
<AppHeader />
|
||||
</AppShell.Header>
|
||||
{!isHomeRoute && (
|
||||
{!hideSidebar && (
|
||||
<AppShell.Navbar
|
||||
className={classes.navbar}
|
||||
withBorder={false}
|
||||
|
||||
@ -1,9 +1,21 @@
|
||||
import { Group, Menu, UnstyledButton, Text } from "@mantine/core";
|
||||
import {
|
||||
Group,
|
||||
Menu,
|
||||
UnstyledButton,
|
||||
Text,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconBrightnessFilled,
|
||||
IconBrush,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconDeviceDesktop,
|
||||
IconLogout,
|
||||
IconMoon,
|
||||
IconSettings,
|
||||
IconSun,
|
||||
IconUserCircle,
|
||||
IconUsers,
|
||||
} from "@tabler/icons-react";
|
||||
@ -19,6 +31,7 @@ export default function TopMenu() {
|
||||
const { t } = useTranslation();
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const { logout } = useAuth();
|
||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||
|
||||
const user = currentUser?.user;
|
||||
const workspace = currentUser?.workspace;
|
||||
@ -75,7 +88,7 @@ export default function TopMenu() {
|
||||
name={user.name}
|
||||
/>
|
||||
|
||||
<div style={{width: 190}}>
|
||||
<div style={{ width: 190 }}>
|
||||
<Text size="sm" fw={500} lineClamp={1}>
|
||||
{user.name}
|
||||
</Text>
|
||||
@ -101,6 +114,44 @@ export default function TopMenu() {
|
||||
{t("My preferences")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Sub>
|
||||
<Menu.Sub.Target>
|
||||
<Menu.Sub.Item leftSection={<IconBrightnessFilled size={16} />}>
|
||||
{t("Theme")}
|
||||
</Menu.Sub.Item>
|
||||
</Menu.Sub.Target>
|
||||
|
||||
<Menu.Sub.Dropdown>
|
||||
<Menu.Item
|
||||
onClick={() => setColorScheme("light")}
|
||||
leftSection={<IconSun size={16} />}
|
||||
rightSection={
|
||||
colorScheme === "light" ? <IconCheck size={16} /> : null
|
||||
}
|
||||
>
|
||||
{t("Light")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => setColorScheme("dark")}
|
||||
leftSection={<IconMoon size={16} />}
|
||||
rightSection={
|
||||
colorScheme === "dark" ? <IconCheck size={16} /> : null
|
||||
}
|
||||
>
|
||||
{t("Dark")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => setColorScheme("auto")}
|
||||
leftSection={<IconDeviceDesktop size={16} />}
|
||||
rightSection={
|
||||
colorScheme === "auto" ? <IconCheck size={16} /> : null
|
||||
}
|
||||
>
|
||||
{t("System settings")}
|
||||
</Menu.Item>
|
||||
</Menu.Sub.Dropdown>
|
||||
</Menu.Sub>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item onClick={logout} leftSection={<IconLogout size={16} />}>
|
||||
|
||||
@ -117,7 +117,8 @@ export default function BillingDetails() {
|
||||
{billing.billingScheme === "tiered" && (
|
||||
<>
|
||||
<Text fw={700} fz="lg">
|
||||
${billing.amount / 100} {billing.currency.toUpperCase()}
|
||||
${billing.amount / 100} {billing.currency.toUpperCase()} /{" "}
|
||||
{billing.interval}
|
||||
</Text>
|
||||
<Text c="dimmed" fz="sm">
|
||||
per {billing.interval}
|
||||
@ -129,7 +130,7 @@ export default function BillingDetails() {
|
||||
<>
|
||||
<Text fw={700} fz="lg">
|
||||
{(billing.amount / 100) * billing.quantity}{" "}
|
||||
{billing.currency.toUpperCase()}
|
||||
{billing.currency.toUpperCase()} / {billing.interval}
|
||||
</Text>
|
||||
<Text c="dimmed" fz="sm">
|
||||
${billing.amount / 100} /user/{billing.interval}
|
||||
|
||||
@ -12,14 +12,18 @@ import {
|
||||
Badge,
|
||||
Flex,
|
||||
Switch,
|
||||
Alert,
|
||||
} from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
import { IconCheck, IconInfoCircle } from "@tabler/icons-react";
|
||||
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
|
||||
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
||||
|
||||
export default function BillingPlans() {
|
||||
const { data: plans } = useBillingPlans();
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const [isAnnual, setIsAnnual] = useState(true);
|
||||
const [selectedTierValue, setSelectedTierValue] = useState<string | null>(
|
||||
null,
|
||||
@ -36,49 +40,76 @@ export default function BillingPlans() {
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: remove by July 30.
|
||||
// Check if workspace was created between June 28 and July 14, 2025
|
||||
const showTieredPricingNotice = (() => {
|
||||
if (!workspace?.createdAt) return false;
|
||||
const createdDate = new Date(workspace.createdAt);
|
||||
const startDate = new Date('2025-06-20');
|
||||
const endDate = new Date('2025-07-14');
|
||||
return createdDate >= startDate && createdDate <= endDate;
|
||||
})();
|
||||
|
||||
if (!plans || plans.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstPlan = plans[0];
|
||||
// Check if any plan is tiered
|
||||
const hasTieredPlans = plans.some(plan => plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0);
|
||||
const firstTieredPlan = plans.find(plan => plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0);
|
||||
|
||||
// Set initial tier value if not set
|
||||
if (!selectedTierValue && firstPlan.pricingTiers.length > 0) {
|
||||
setSelectedTierValue(firstPlan.pricingTiers[0].upTo.toString());
|
||||
// Set initial tier value if not set and we have tiered plans
|
||||
if (hasTieredPlans && !selectedTierValue && firstTieredPlan) {
|
||||
setSelectedTierValue(firstTieredPlan.pricingTiers[0].upTo.toString());
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!selectedTierValue) {
|
||||
// For tiered plans, ensure we have a selected tier
|
||||
if (hasTieredPlans && !selectedTierValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectData = firstPlan.pricingTiers
|
||||
.filter((tier) => !tier.custom)
|
||||
const selectData = firstTieredPlan?.pricingTiers
|
||||
?.filter((tier) => !tier.custom)
|
||||
.map((tier, index) => {
|
||||
const prevMaxUsers =
|
||||
index > 0 ? firstPlan.pricingTiers[index - 1].upTo : 0;
|
||||
index > 0 ? firstTieredPlan.pricingTiers[index - 1].upTo : 0;
|
||||
return {
|
||||
value: tier.upTo.toString(),
|
||||
label: `${prevMaxUsers + 1}-${tier.upTo} users`,
|
||||
};
|
||||
});
|
||||
}) || [];
|
||||
|
||||
return (
|
||||
<Container size="xl" py="xl">
|
||||
{/* Tiered pricing notice for eligible workspaces */}
|
||||
{showTieredPricingNotice && !hasTieredPlans && (
|
||||
<Alert
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
title="Want the old tiered pricing?"
|
||||
color="blue"
|
||||
mb="lg"
|
||||
>
|
||||
Contact support to switch back to our tiered pricing model.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Controls Section */}
|
||||
<Stack gap="xl" mb="md">
|
||||
{/* Team Size and Billing Controls */}
|
||||
<Group justify="center" align="center" gap="sm">
|
||||
<Select
|
||||
label="Team size"
|
||||
description="Select the number of users"
|
||||
value={selectedTierValue}
|
||||
onChange={setSelectedTierValue}
|
||||
data={selectData}
|
||||
w={250}
|
||||
size="md"
|
||||
allowDeselect={false}
|
||||
/>
|
||||
{hasTieredPlans && (
|
||||
<Select
|
||||
label="Team size"
|
||||
description="Select the number of users"
|
||||
value={selectedTierValue}
|
||||
onChange={setSelectedTierValue}
|
||||
data={selectData}
|
||||
w={250}
|
||||
size="md"
|
||||
allowDeselect={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Group justify="center" align="start">
|
||||
<Flex justify="center" gap="md" align="center">
|
||||
@ -102,17 +133,29 @@ export default function BillingPlans() {
|
||||
{/* Plans Grid */}
|
||||
<Group justify="center" gap="lg" align="stretch">
|
||||
{plans.map((plan, index) => {
|
||||
const tieredPlan = plan;
|
||||
const planSelectedTier =
|
||||
tieredPlan.pricingTiers.find(
|
||||
(tier) => tier.upTo.toString() === selectedTierValue,
|
||||
) || tieredPlan.pricingTiers[0];
|
||||
|
||||
const price = isAnnual
|
||||
? planSelectedTier.yearly
|
||||
: planSelectedTier.monthly;
|
||||
let price;
|
||||
let displayPrice;
|
||||
const priceId = isAnnual ? plan.yearlyId : plan.monthlyId;
|
||||
|
||||
if (plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0) {
|
||||
// Tiered billing logic
|
||||
const planSelectedTier =
|
||||
plan.pricingTiers.find(
|
||||
(tier) => tier.upTo.toString() === selectedTierValue,
|
||||
) || plan.pricingTiers[0];
|
||||
|
||||
price = isAnnual
|
||||
? planSelectedTier.yearly
|
||||
: planSelectedTier.monthly;
|
||||
displayPrice = isAnnual ? (price / 12).toFixed(0) : price;
|
||||
} else {
|
||||
// Per-unit billing logic
|
||||
const monthlyPrice = parseFloat(plan.price?.monthly || '0');
|
||||
const yearlyPrice = parseFloat(plan.price?.yearly || '0');
|
||||
price = isAnnual ? yearlyPrice : monthlyPrice;
|
||||
displayPrice = isAnnual ? (yearlyPrice / 12).toFixed(0) : monthlyPrice;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={plan.name}
|
||||
@ -143,25 +186,27 @@ export default function BillingPlans() {
|
||||
<Stack gap="xs">
|
||||
<Group align="baseline" gap="xs">
|
||||
<Title order={1} size="h1">
|
||||
${isAnnual ? (price / 12).toFixed(0) : price}
|
||||
${displayPrice}
|
||||
</Title>
|
||||
<Text size="lg" c="dimmed">
|
||||
per {isAnnual ? "month" : "month"}
|
||||
{plan.billingScheme === 'per_unit'
|
||||
? `per user/month`
|
||||
: `per month`}
|
||||
</Text>
|
||||
</Group>
|
||||
{isAnnual && (
|
||||
<Text size="sm" c="dimmed">
|
||||
Billed annually
|
||||
<Text size="sm" c="dimmed">
|
||||
{isAnnual ? "Billed annually" : "Billed monthly"}
|
||||
</Text>
|
||||
{plan.billingScheme === 'tiered' && plan.pricingTiers && (
|
||||
<Text size="md" fw={500}>
|
||||
For {plan.pricingTiers.find(tier => tier.upTo.toString() === selectedTierValue)?.upTo || plan.pricingTiers[0].upTo} users
|
||||
</Text>
|
||||
)}
|
||||
<Text size="md" fw={500}>
|
||||
For {planSelectedTier.upTo} users
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{/* CTA Button */}
|
||||
<Button onClick={() => handleCheckout(priceId)} fullWidth>
|
||||
Upgrade
|
||||
Subscribe
|
||||
</Button>
|
||||
|
||||
{/* Features */}
|
||||
|
||||
@ -53,7 +53,7 @@ export interface IBillingPlan {
|
||||
};
|
||||
features: string[];
|
||||
billingScheme: string | null;
|
||||
pricingTiers: PricingTier[];
|
||||
pricingTiers?: PricingTier[];
|
||||
}
|
||||
|
||||
interface PricingTier {
|
||||
|
||||
81
apps/client/src/ee/mfa/components/mfa-backup-code-input.tsx
Normal file
81
apps/client/src/ee/mfa/components/mfa-backup-code-input.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import React from "react";
|
||||
import {
|
||||
TextInput,
|
||||
Button,
|
||||
Stack,
|
||||
Text,
|
||||
Alert,
|
||||
} from "@mantine/core";
|
||||
import { IconKey, IconAlertCircle } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface MfaBackupCodeInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: string;
|
||||
onSubmit: () => void;
|
||||
onCancel: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function MfaBackupCodeInput({
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isLoading,
|
||||
}: MfaBackupCodeInputProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="blue" variant="light">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"Enter one of your backup codes. Each backup code can only be used once.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<TextInput
|
||||
label={t("Backup code")}
|
||||
placeholder="XXXXXXXX"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.currentTarget.value.toUpperCase())}
|
||||
error={error}
|
||||
autoFocus
|
||||
maxLength={8}
|
||||
styles={{
|
||||
input: {
|
||||
fontFamily: "monospace",
|
||||
letterSpacing: "0.1em",
|
||||
fontSize: "1rem",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack>
|
||||
<Button
|
||||
fullWidth
|
||||
size="md"
|
||||
loading={isLoading}
|
||||
onClick={onSubmit}
|
||||
leftSection={<IconKey size={18} />}
|
||||
>
|
||||
{t("Verify backup code")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={onCancel}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t("Use authenticator app instead")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
193
apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx
Normal file
193
apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx
Normal file
@ -0,0 +1,193 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Button,
|
||||
Paper,
|
||||
Group,
|
||||
List,
|
||||
Code,
|
||||
CopyButton,
|
||||
Alert,
|
||||
PasswordInput,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconRefresh,
|
||||
IconCopy,
|
||||
IconCheck,
|
||||
IconAlertCircle,
|
||||
} from "@tabler/icons-react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { regenerateBackupCodes } from "@/ee/mfa";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zodResolver } from "mantine-form-zod-resolver";
|
||||
import { z } from "zod";
|
||||
|
||||
interface MfaBackupCodesModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const formSchema = z.object({
|
||||
confirmPassword: z.string().min(1, { message: "Password is required" }),
|
||||
});
|
||||
|
||||
export function MfaBackupCodesModal({
|
||||
opened,
|
||||
onClose,
|
||||
}: MfaBackupCodesModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const [showNewCodes, setShowNewCodes] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
validate: zodResolver(formSchema),
|
||||
initialValues: {
|
||||
confirmPassword: "",
|
||||
},
|
||||
});
|
||||
|
||||
const regenerateMutation = useMutation({
|
||||
mutationFn: (data: { confirmPassword: string }) =>
|
||||
regenerateBackupCodes(data),
|
||||
onSuccess: (data) => {
|
||||
setBackupCodes(data.backupCodes);
|
||||
setShowNewCodes(true);
|
||||
form.reset();
|
||||
notifications.show({
|
||||
title: t("Success"),
|
||||
message: t("New backup codes have been generated"),
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
notifications.show({
|
||||
title: t("Error"),
|
||||
message:
|
||||
error.response?.data?.message ||
|
||||
t("Failed to regenerate backup codes"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleRegenerate = (values: { confirmPassword: string }) => {
|
||||
regenerateMutation.mutate(values);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setShowNewCodes(false);
|
||||
setBackupCodes([]);
|
||||
form.reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={t("Backup codes")}
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
{!showNewCodes ? (
|
||||
<form onSubmit={form.onSubmit(handleRegenerate)}>
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={20} />}
|
||||
title={t("About backup codes")}
|
||||
color="blue"
|
||||
variant="light"
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"You can regenerate new backup codes at any time. This will invalidate all existing codes.",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<PasswordInput
|
||||
label={t("Confirm password")}
|
||||
placeholder={t("Enter your password")}
|
||||
variant="filled"
|
||||
{...form.getInputProps("confirmPassword")}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
loading={regenerateMutation.isPending}
|
||||
leftSection={<IconRefresh size={18} />}
|
||||
>
|
||||
{t("Generate new backup codes")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={20} />}
|
||||
title={t("Save your new backup codes")}
|
||||
color="yellow"
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Paper p="md" withBorder>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text size="sm" fw={600}>
|
||||
{t("Your new backup codes")}
|
||||
</Text>
|
||||
<CopyButton value={backupCodes.join("\n")}>
|
||||
{({ copied, copy }) => (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onClick={copy}
|
||||
leftSection={
|
||||
copied ? (
|
||||
<IconCheck size={14} />
|
||||
) : (
|
||||
<IconCopy size={14} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{copied ? t("Copied") : t("Copy")}
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Group>
|
||||
<List size="sm" spacing="xs">
|
||||
{backupCodes.map((code, index) => (
|
||||
<List.Item key={index}>
|
||||
<Code>{code}</Code>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
onClick={handleClose}
|
||||
leftSection={<IconCheck size={18} />}
|
||||
>
|
||||
{t("I've saved my backup codes")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
12
apps/client/src/ee/mfa/components/mfa-challenge.module.css
Normal file
12
apps/client/src/ee/mfa/components/mfa-challenge.module.css
Normal file
@ -0,0 +1,12 @@
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.paper {
|
||||
width: 100%;
|
||||
box-shadow: var(--mantine-shadow-lg);
|
||||
}
|
||||
160
apps/client/src/ee/mfa/components/mfa-challenge.tsx
Normal file
160
apps/client/src/ee/mfa/components/mfa-challenge.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Container,
|
||||
Title,
|
||||
Text,
|
||||
PinInput,
|
||||
Button,
|
||||
Stack,
|
||||
Anchor,
|
||||
Paper,
|
||||
Center,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zodResolver } from "mantine-form-zod-resolver";
|
||||
import { IconDeviceMobile, IconLock } from "@tabler/icons-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import classes from "./mfa-challenge.module.css";
|
||||
import { verifyMfa } from "@/ee/mfa";
|
||||
import APP_ROUTE from "@/lib/app-route";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as z from "zod";
|
||||
import { MfaBackupCodeInput } from "./mfa-backup-code-input";
|
||||
|
||||
const formSchema = z.object({
|
||||
code: z
|
||||
.string()
|
||||
.refine(
|
||||
(val) => (val.length === 6 && /^\d{6}$/.test(val)) || val.length === 8,
|
||||
{
|
||||
message: "Enter a 6-digit code or 8-character backup code",
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
type MfaChallengeFormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function MfaChallenge() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [useBackupCode, setUseBackupCode] = useState(false);
|
||||
|
||||
const form = useForm<MfaChallengeFormValues>({
|
||||
validate: zodResolver(formSchema),
|
||||
initialValues: {
|
||||
code: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: MfaChallengeFormValues) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await verifyMfa(values.code);
|
||||
navigate(APP_ROUTE.HOME);
|
||||
} catch (error: any) {
|
||||
setIsLoading(false);
|
||||
notifications.show({
|
||||
message:
|
||||
error.response?.data?.message || t("Invalid verification code"),
|
||||
color: "red",
|
||||
});
|
||||
form.setFieldValue("code", "");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size={420} className={classes.container}>
|
||||
<Paper radius="lg" p={40} className={classes.paper}>
|
||||
<Stack align="center" gap="xl">
|
||||
<Center>
|
||||
<ThemeIcon size={80} radius="xl" variant="light" color="blue">
|
||||
<IconDeviceMobile size={40} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
</Center>
|
||||
|
||||
<Stack align="center" gap="xs">
|
||||
<Title order={2} ta="center" fw={600}>
|
||||
{t("Two-factor authentication")}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{useBackupCode
|
||||
? t("Enter one of your backup codes")
|
||||
: t("Enter the 6-digit code found in your authenticator app")}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{!useBackupCode ? (
|
||||
<form
|
||||
onSubmit={form.onSubmit(handleSubmit)}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<Center>
|
||||
<PinInput
|
||||
length={6}
|
||||
type="number"
|
||||
autoFocus
|
||||
oneTimeCode
|
||||
{...form.getInputProps("code")}
|
||||
error={!!form.errors.code}
|
||||
styles={{
|
||||
input: {
|
||||
fontSize: "1.2rem",
|
||||
textAlign: "center",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Center>
|
||||
{form.errors.code && (
|
||||
<Text c="red" size="sm" ta="center">
|
||||
{form.errors.code}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
size="md"
|
||||
loading={isLoading}
|
||||
leftSection={<IconLock size={18} />}
|
||||
>
|
||||
{t("Verify")}
|
||||
</Button>
|
||||
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
onClick={() => {
|
||||
setUseBackupCode(true);
|
||||
form.setFieldValue("code", "");
|
||||
form.clearErrors();
|
||||
}}
|
||||
>
|
||||
{t("Use backup code")}
|
||||
</Anchor>
|
||||
</Stack>
|
||||
</form>
|
||||
) : (
|
||||
<MfaBackupCodeInput
|
||||
value={form.values.code}
|
||||
onChange={(value) => form.setFieldValue("code", value)}
|
||||
error={form.errors.code?.toString()}
|
||||
onSubmit={() => handleSubmit(form.values)}
|
||||
onCancel={() => {
|
||||
setUseBackupCode(false);
|
||||
form.setFieldValue("code", "");
|
||||
form.clearErrors();
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
124
apps/client/src/ee/mfa/components/mfa-disable-modal.tsx
Normal file
124
apps/client/src/ee/mfa/components/mfa-disable-modal.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Button,
|
||||
PasswordInput,
|
||||
Alert,
|
||||
} from "@mantine/core";
|
||||
import { IconShieldOff, IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zodResolver } from "mantine-form-zod-resolver";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { disableMfa } from "@/ee/mfa";
|
||||
|
||||
interface MfaDisableModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
const formSchema = z.object({
|
||||
confirmPassword: z.string().min(1, { message: "Password is required" }),
|
||||
});
|
||||
|
||||
export function MfaDisableModal({
|
||||
opened,
|
||||
onClose,
|
||||
onComplete,
|
||||
}: MfaDisableModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const form = useForm({
|
||||
validate: zodResolver(formSchema),
|
||||
initialValues: {
|
||||
confirmPassword: "",
|
||||
},
|
||||
});
|
||||
|
||||
const disableMutation = useMutation({
|
||||
mutationFn: disableMfa,
|
||||
onSuccess: () => {
|
||||
onComplete();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
notifications.show({
|
||||
title: t("Error"),
|
||||
message: error.response?.data?.message || t("Failed to disable MFA"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: { confirmPassword: string }) => {
|
||||
await disableMutation.mutateAsync(values);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
form.reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={t("Disable two-factor authentication")}
|
||||
size="md"
|
||||
>
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
icon={<IconAlertTriangle size={20} />}
|
||||
title={t("Warning")}
|
||||
color="red"
|
||||
variant="light"
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"Please enter your password to disable two-factor authentication:",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<PasswordInput
|
||||
label={t("Password")}
|
||||
placeholder={t("Enter your password")}
|
||||
{...form.getInputProps("confirmPassword")}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<Stack gap="sm">
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
color="red"
|
||||
loading={disableMutation.isPending}
|
||||
leftSection={<IconShieldOff size={18} />}
|
||||
>
|
||||
{t("Disable two-factor authentication")}
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="default"
|
||||
onClick={handleClose}
|
||||
disabled={disableMutation.isPending}
|
||||
>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
112
apps/client/src/ee/mfa/components/mfa-settings.tsx
Normal file
112
apps/client/src/ee/mfa/components/mfa-settings.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import React, { useState } from "react";
|
||||
import { Group, Text, Button } from "@mantine/core";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getMfaStatus } from "@/ee/mfa";
|
||||
import { MfaSetupModal } from "@/ee/mfa";
|
||||
import { MfaDisableModal } from "@/ee/mfa";
|
||||
import { MfaBackupCodesModal } from "@/ee/mfa";
|
||||
|
||||
export function MfaSettings() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [setupModalOpen, setSetupModalOpen] = useState(false);
|
||||
const [disableModalOpen, setDisableModalOpen] = useState(false);
|
||||
const [backupCodesModalOpen, setBackupCodesModalOpen] = useState(false);
|
||||
|
||||
const { data: mfaStatus, isLoading } = useQuery({
|
||||
queryKey: ["mfa-status"],
|
||||
queryFn: getMfaStatus,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if MFA is truly enabled
|
||||
const isMfaEnabled = mfaStatus?.isEnabled === true;
|
||||
|
||||
const handleSetupComplete = () => {
|
||||
setSetupModalOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["mfa-status"] });
|
||||
notifications.show({
|
||||
title: t("Success"),
|
||||
message: t("Two-factor authentication has been enabled"),
|
||||
});
|
||||
};
|
||||
|
||||
const handleDisableComplete = () => {
|
||||
setDisableModalOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["mfa-status"] });
|
||||
notifications.show({
|
||||
title: t("Success"),
|
||||
message: t("Two-factor authentication has been disabled"),
|
||||
color: "blue",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="md">{t("2-step verification")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{!isMfaEnabled
|
||||
? t(
|
||||
"Protect your account with an additional verification layer when signing in.",
|
||||
)
|
||||
: t("Two-factor authentication is active on your account.")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{!isMfaEnabled ? (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setSetupModalOpen(true)}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{t("Add 2FA method")}
|
||||
</Button>
|
||||
) : (
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => setBackupCodesModalOpen(true)}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{t("Backup codes")} ({mfaStatus?.backupCodesCount || 0})
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
color="red"
|
||||
onClick={() => setDisableModalOpen(true)}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{t("Disable")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<MfaSetupModal
|
||||
opened={setupModalOpen}
|
||||
onClose={() => setSetupModalOpen(false)}
|
||||
onComplete={handleSetupComplete}
|
||||
/>
|
||||
|
||||
<MfaDisableModal
|
||||
opened={disableModalOpen}
|
||||
onClose={() => setDisableModalOpen(false)}
|
||||
onComplete={handleDisableComplete}
|
||||
/>
|
||||
|
||||
<MfaBackupCodesModal
|
||||
opened={backupCodesModalOpen}
|
||||
onClose={() => setBackupCodesModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
347
apps/client/src/ee/mfa/components/mfa-setup-modal.tsx
Normal file
347
apps/client/src/ee/mfa/components/mfa-setup-modal.tsx
Normal file
@ -0,0 +1,347 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Button,
|
||||
Group,
|
||||
Stepper,
|
||||
Center,
|
||||
Image,
|
||||
PinInput,
|
||||
Alert,
|
||||
List,
|
||||
CopyButton,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Paper,
|
||||
Code,
|
||||
Loader,
|
||||
Collapse,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconQrcode,
|
||||
IconShieldCheck,
|
||||
IconKey,
|
||||
IconCopy,
|
||||
IconCheck,
|
||||
IconAlertCircle,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconPrinter,
|
||||
} from "@tabler/icons-react";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { setupMfa, enableMfa } from "@/ee/mfa";
|
||||
import { zodResolver } from "mantine-form-zod-resolver";
|
||||
import { z } from "zod";
|
||||
|
||||
interface MfaSetupModalProps {
|
||||
opened: boolean;
|
||||
onClose?: () => void;
|
||||
onComplete: () => void;
|
||||
isRequired?: boolean;
|
||||
}
|
||||
|
||||
interface SetupData {
|
||||
secret: string;
|
||||
qrCode: string;
|
||||
manualKey: string;
|
||||
}
|
||||
|
||||
const formSchema = z.object({
|
||||
verificationCode: z
|
||||
.string()
|
||||
.length(6, { message: "Please enter a 6-digit code" }),
|
||||
});
|
||||
|
||||
export function MfaSetupModal({
|
||||
opened,
|
||||
onClose,
|
||||
onComplete,
|
||||
isRequired = false,
|
||||
}: MfaSetupModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [active, setActive] = useState(0);
|
||||
const [setupData, setSetupData] = useState<SetupData | null>(null);
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const [manualEntryOpen, setManualEntryOpen] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
validate: zodResolver(formSchema),
|
||||
initialValues: {
|
||||
verificationCode: "",
|
||||
},
|
||||
});
|
||||
|
||||
const setupMutation = useMutation({
|
||||
mutationFn: () => setupMfa({ method: "totp" }),
|
||||
onSuccess: (data) => {
|
||||
setSetupData(data);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
notifications.show({
|
||||
title: t("Error"),
|
||||
message: error.response?.data?.message || t("Failed to setup MFA"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Generate QR code when modal opens
|
||||
React.useEffect(() => {
|
||||
if (opened && !setupData && !setupMutation.isPending) {
|
||||
setupMutation.mutate();
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
const enableMutation = useMutation({
|
||||
mutationFn: (verificationCode: string) =>
|
||||
enableMfa({
|
||||
secret: setupData!.secret,
|
||||
verificationCode,
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
setBackupCodes(data.backupCodes);
|
||||
setActive(1); // Move to backup codes step
|
||||
},
|
||||
onError: (error: any) => {
|
||||
notifications.show({
|
||||
title: t("Error"),
|
||||
message:
|
||||
error.response?.data?.message || t("Invalid verification code"),
|
||||
color: "red",
|
||||
});
|
||||
form.setFieldValue("verificationCode", "");
|
||||
},
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
if (active === 1 && backupCodes.length > 0) {
|
||||
onComplete();
|
||||
}
|
||||
onClose();
|
||||
// Reset state
|
||||
setTimeout(() => {
|
||||
setActive(0);
|
||||
setSetupData(null);
|
||||
setBackupCodes([]);
|
||||
setManualEntryOpen(false);
|
||||
form.reset();
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const handleVerify = async (values: { verificationCode: string }) => {
|
||||
await enableMutation.mutateAsync(values.verificationCode);
|
||||
};
|
||||
|
||||
const handlePrintBackupCodes = () => {
|
||||
window.print();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={t("Set up two-factor authentication")}
|
||||
size="md"
|
||||
>
|
||||
<Stepper active={active} size="sm">
|
||||
<Stepper.Step
|
||||
label={t("Setup & Verify")}
|
||||
description={t("Add to authenticator")}
|
||||
icon={<IconQrcode size={18} />}
|
||||
>
|
||||
<form onSubmit={form.onSubmit(handleVerify)}>
|
||||
<Stack gap="md" mt="xl">
|
||||
{setupMutation.isPending ? (
|
||||
<Center py="xl">
|
||||
<Loader size="lg" />
|
||||
</Center>
|
||||
) : setupData ? (
|
||||
<>
|
||||
<Text size="sm">
|
||||
{t("1. Scan this QR code with your authenticator app")}
|
||||
</Text>
|
||||
|
||||
<Center>
|
||||
<Paper p="md" withBorder>
|
||||
<Image
|
||||
src={setupData.qrCode}
|
||||
alt="MFA QR Code"
|
||||
width={200}
|
||||
height={200}
|
||||
/>
|
||||
</Paper>
|
||||
</Center>
|
||||
|
||||
<UnstyledButton
|
||||
onClick={() => setManualEntryOpen(!manualEntryOpen)}
|
||||
>
|
||||
<Group gap="xs">
|
||||
{manualEntryOpen ? (
|
||||
<IconChevronDown size={16} />
|
||||
) : (
|
||||
<IconChevronRight size={16} />
|
||||
)}
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Can't scan the code?")}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
<Collapse in={manualEntryOpen}>
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={20} />}
|
||||
color="gray"
|
||||
variant="light"
|
||||
>
|
||||
<Text size="sm" mb="sm">
|
||||
{t(
|
||||
"Enter this code manually in your authenticator app:",
|
||||
)}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Code block>{setupData.manualKey}</Code>
|
||||
<CopyButton value={setupData.manualKey}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip label={copied ? t("Copied") : t("Copy")}>
|
||||
<ActionIcon
|
||||
color={copied ? "green" : "gray"}
|
||||
onClick={copy}
|
||||
>
|
||||
{copied ? (
|
||||
<IconCheck size={16} />
|
||||
) : (
|
||||
<IconCopy size={16} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Group>
|
||||
</Alert>
|
||||
</Collapse>
|
||||
|
||||
<Text size="sm" mt="md">
|
||||
{t("2. Enter the 6-digit code from your authenticator")}
|
||||
</Text>
|
||||
|
||||
<Stack align="center">
|
||||
<PinInput
|
||||
length={6}
|
||||
type="number"
|
||||
autoFocus
|
||||
oneTimeCode
|
||||
{...form.getInputProps("verificationCode")}
|
||||
styles={{
|
||||
input: {
|
||||
fontSize: "1.2rem",
|
||||
textAlign: "center",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{form.errors.verificationCode && (
|
||||
<Text c="red" size="sm">
|
||||
{form.errors.verificationCode}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
loading={enableMutation.isPending}
|
||||
leftSection={<IconShieldCheck size={18} />}
|
||||
>
|
||||
{t("Verify and enable")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Center py="xl">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Failed to generate QR code. Please try again.")}
|
||||
</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Stack>
|
||||
</form>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label={t("Backup")}
|
||||
description={t("Save codes")}
|
||||
icon={<IconKey size={18} />}
|
||||
>
|
||||
<Stack gap="md" mt="xl">
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={20} />}
|
||||
title={t("Save your backup codes")}
|
||||
color="yellow"
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Paper p="md" withBorder>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text size="sm" fw={600}>
|
||||
{t("Backup codes")}
|
||||
</Text>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<CopyButton value={backupCodes.join("\n")}>
|
||||
{({ copied, copy }) => (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onClick={copy}
|
||||
leftSection={
|
||||
copied ? (
|
||||
<IconCheck size={14} />
|
||||
) : (
|
||||
<IconCopy size={14} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{copied ? t("Copied") : t("Copy")}
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onClick={handlePrintBackupCodes}
|
||||
leftSection={<IconPrinter size={14} />}
|
||||
>
|
||||
{t("Print")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
<List size="sm" spacing="xs">
|
||||
{backupCodes.map((code, index) => (
|
||||
<List.Item key={index}>
|
||||
<Code>{code}</Code>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
onClick={handleClose}
|
||||
leftSection={<IconCheck size={18} />}
|
||||
>
|
||||
{t("I've saved my backup codes")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
</Stepper>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
48
apps/client/src/ee/mfa/components/mfa-setup-required.tsx
Normal file
48
apps/client/src/ee/mfa/components/mfa-setup-required.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import React from "react";
|
||||
import { Container, Paper, Title, Text, Alert, Stack } from "@mantine/core";
|
||||
import { IconAlertCircle } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MfaSetupModal } from "@/ee/mfa";
|
||||
import APP_ROUTE from "@/lib/app-route.ts";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export default function MfaSetupRequired() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSetupComplete = () => {
|
||||
navigate(APP_ROUTE.HOME);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
<Paper shadow="sm" p="xl" radius="md" withBorder>
|
||||
<Stack>
|
||||
<Title order={2} ta="center">
|
||||
{t("Two-factor authentication required")}
|
||||
</Title>
|
||||
|
||||
<Alert icon={<IconAlertCircle size="1rem" />} color="yellow">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"Your workspace requires two-factor authentication. Please set it up to continue.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
{t(
|
||||
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<MfaSetupModal
|
||||
opened={true}
|
||||
onComplete={handleSetupComplete}
|
||||
isRequired={true}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
31
apps/client/src/ee/mfa/components/mfa.module.css
Normal file
31
apps/client/src/ee/mfa/components/mfa.module.css
Normal file
@ -0,0 +1,31 @@
|
||||
.qrCodeContainer {
|
||||
background-color: white;
|
||||
padding: 1rem;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.backupCodesList {
|
||||
font-family: var(--mantine-font-family-monospace);
|
||||
background-color: var(--mantine-color-gray-0);
|
||||
padding: 1rem;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
|
||||
@mixin dark {
|
||||
background-color: var(--mantine-color-dark-7);
|
||||
}
|
||||
}
|
||||
|
||||
.codeItem {
|
||||
padding: 0.25rem 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.setupStep {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.verificationInput {
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
51
apps/client/src/ee/mfa/hooks/use-mfa-page-protection.ts
Normal file
51
apps/client/src/ee/mfa/hooks/use-mfa-page-protection.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import APP_ROUTE from "@/lib/app-route";
|
||||
import { validateMfaAccess } from "@/ee/mfa";
|
||||
|
||||
export function useMfaPageProtection() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [isValidating, setIsValidating] = useState(true);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAccess = async () => {
|
||||
const result = await validateMfaAccess();
|
||||
|
||||
if (!result.valid) {
|
||||
navigate(APP_ROUTE.AUTH.LOGIN);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user is on the correct page based on their MFA state
|
||||
const isOnChallengePage =
|
||||
location.pathname === APP_ROUTE.AUTH.MFA_CHALLENGE;
|
||||
const isOnSetupPage =
|
||||
location.pathname === APP_ROUTE.AUTH.MFA_SETUP_REQUIRED;
|
||||
|
||||
if (result.requiresMfaSetup && !isOnSetupPage) {
|
||||
// User needs to set up MFA but is on challenge page
|
||||
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
|
||||
} else if (
|
||||
!result.requiresMfaSetup &&
|
||||
result.userHasMfa &&
|
||||
!isOnChallengePage
|
||||
) {
|
||||
// User has MFA and should be on challenge page
|
||||
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
|
||||
} else if (!result.isTransferToken) {
|
||||
// User has a regular auth token, shouldn't be on MFA pages
|
||||
navigate(APP_ROUTE.HOME);
|
||||
} else {
|
||||
setIsValid(true);
|
||||
}
|
||||
|
||||
setIsValidating(false);
|
||||
};
|
||||
|
||||
checkAccess();
|
||||
}, [navigate, location.pathname]);
|
||||
|
||||
return { isValidating, isValid };
|
||||
}
|
||||
19
apps/client/src/ee/mfa/index.ts
Normal file
19
apps/client/src/ee/mfa/index.ts
Normal file
@ -0,0 +1,19 @@
|
||||
// Components
|
||||
export { MfaChallenge } from "./components/mfa-challenge";
|
||||
export { MfaSettings } from "./components/mfa-settings";
|
||||
export { MfaSetupModal } from "./components/mfa-setup-modal";
|
||||
export { MfaDisableModal } from "./components/mfa-disable-modal";
|
||||
export { MfaBackupCodesModal } from "./components/mfa-backup-codes-modal";
|
||||
|
||||
// Pages
|
||||
export { MfaChallengePage } from "./pages/mfa-challenge-page";
|
||||
export { MfaSetupRequiredPage } from "./pages/mfa-setup-required-page";
|
||||
|
||||
// Services
|
||||
export * from "./services/mfa-service";
|
||||
|
||||
// Types
|
||||
export * from "./types/mfa.types";
|
||||
|
||||
// Hooks
|
||||
export { useMfaPageProtection } from "./hooks/use-mfa-page-protection.ts";
|
||||
13
apps/client/src/ee/mfa/pages/mfa-challenge-page.tsx
Normal file
13
apps/client/src/ee/mfa/pages/mfa-challenge-page.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import { MfaChallenge } from "@/ee/mfa";
|
||||
import { useMfaPageProtection } from "@/ee/mfa";
|
||||
|
||||
export function MfaChallengePage() {
|
||||
const { isValid } = useMfaPageProtection();
|
||||
|
||||
if (!isValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <MfaChallenge />;
|
||||
}
|
||||
113
apps/client/src/ee/mfa/pages/mfa-setup-required-page.tsx
Normal file
113
apps/client/src/ee/mfa/pages/mfa-setup-required-page.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
import React, { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Container,
|
||||
Title,
|
||||
Text,
|
||||
Button,
|
||||
Stack,
|
||||
Paper,
|
||||
Alert,
|
||||
Center,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { IconShieldCheck, IconAlertCircle } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import APP_ROUTE from "@/lib/app-route";
|
||||
import { MfaSetupModal } from "@/ee/mfa";
|
||||
import classes from "@/features/auth/components/auth.module.css";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useMfaPageProtection } from "@/ee/mfa";
|
||||
|
||||
export function MfaSetupRequiredPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [setupModalOpen, setSetupModalOpen] = useState(false);
|
||||
const { isValid } = useMfaPageProtection();
|
||||
|
||||
const handleSetupComplete = async () => {
|
||||
setSetupModalOpen(false);
|
||||
|
||||
notifications.show({
|
||||
title: t("Success"),
|
||||
message: t(
|
||||
"Two-factor authentication has been set up. Please log in again.",
|
||||
),
|
||||
});
|
||||
|
||||
navigate(APP_ROUTE.AUTH.LOGIN);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
navigate(APP_ROUTE.AUTH.LOGIN);
|
||||
};
|
||||
|
||||
if (!isValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size={480} className={classes.container}>
|
||||
<Paper radius="lg" p={40}>
|
||||
<Stack align="center" gap="xl">
|
||||
<Center>
|
||||
<ThemeIcon size={80} radius="xl" variant="light" color="blue">
|
||||
<IconShieldCheck size={40} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
</Center>
|
||||
|
||||
<Stack align="center" gap="xs">
|
||||
<Title order={2} ta="center" fw={600}>
|
||||
{t("Two-factor authentication required")}
|
||||
</Title>
|
||||
<Text size="md" c="dimmed" ta="center">
|
||||
{t(
|
||||
"Your workspace requires two-factor authentication for all users",
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={20} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
w="100%"
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Stack w="100%" gap="sm">
|
||||
<Button
|
||||
fullWidth
|
||||
size="md"
|
||||
onClick={() => setSetupModalOpen(true)}
|
||||
leftSection={<IconShieldCheck size={18} />}
|
||||
>
|
||||
{t("Set up two-factor authentication")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
{t("Cancel and logout")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<MfaSetupModal
|
||||
opened={setupModalOpen}
|
||||
onClose={() => setSetupModalOpen(false)}
|
||||
onComplete={handleSetupComplete}
|
||||
isRequired={true}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
61
apps/client/src/ee/mfa/services/mfa-service.ts
Normal file
61
apps/client/src/ee/mfa/services/mfa-service.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import api from "@/lib/api-client";
|
||||
import {
|
||||
MfaBackupCodesResponse,
|
||||
MfaDisableRequest,
|
||||
MfaEnableRequest,
|
||||
MfaEnableResponse,
|
||||
MfaSetupRequest,
|
||||
MfaSetupResponse,
|
||||
MfaStatusResponse,
|
||||
MfaAccessValidationResponse,
|
||||
} from "@/ee/mfa";
|
||||
|
||||
export async function getMfaStatus(): Promise<MfaStatusResponse> {
|
||||
const req = await api.post("/mfa/status");
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function setupMfa(
|
||||
data: MfaSetupRequest,
|
||||
): Promise<MfaSetupResponse> {
|
||||
const req = await api.post<MfaSetupResponse>("/mfa/setup", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function enableMfa(
|
||||
data: MfaEnableRequest,
|
||||
): Promise<MfaEnableResponse> {
|
||||
const req = await api.post<MfaEnableResponse>("/mfa/enable", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function disableMfa(
|
||||
data: MfaDisableRequest,
|
||||
): Promise<{ success: boolean }> {
|
||||
const req = await api.post<{ success: boolean }>("/mfa/disable", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function regenerateBackupCodes(data: {
|
||||
confirmPassword: string;
|
||||
}): Promise<MfaBackupCodesResponse> {
|
||||
const req = await api.post<MfaBackupCodesResponse>(
|
||||
"/mfa/generate-backup-codes",
|
||||
data,
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function verifyMfa(code: string): Promise<any> {
|
||||
const req = await api.post("/mfa/verify", { code });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function validateMfaAccess(): Promise<MfaAccessValidationResponse> {
|
||||
try {
|
||||
const res = await api.post("/mfa/validate-access");
|
||||
return res.data;
|
||||
} catch {
|
||||
return { valid: false };
|
||||
}
|
||||
}
|
||||
62
apps/client/src/ee/mfa/types/mfa.types.ts
Normal file
62
apps/client/src/ee/mfa/types/mfa.types.ts
Normal file
@ -0,0 +1,62 @@
|
||||
export interface MfaMethod {
|
||||
type: 'totp' | 'email';
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface MfaSettings {
|
||||
isEnabled: boolean;
|
||||
methods: MfaMethod[];
|
||||
backupCodesCount: number;
|
||||
lastUpdated?: string;
|
||||
}
|
||||
|
||||
export interface MfaSetupState {
|
||||
method: 'totp' | 'email';
|
||||
secret?: string;
|
||||
qrCode?: string;
|
||||
manualEntry?: string;
|
||||
backupCodes?: string[];
|
||||
}
|
||||
|
||||
export interface MfaStatusResponse {
|
||||
isEnabled?: boolean;
|
||||
method?: string | null;
|
||||
backupCodesCount?: number;
|
||||
}
|
||||
|
||||
export interface MfaSetupRequest {
|
||||
method: 'totp';
|
||||
}
|
||||
|
||||
export interface MfaSetupResponse {
|
||||
method: string;
|
||||
qrCode: string;
|
||||
secret: string;
|
||||
manualKey: string;
|
||||
}
|
||||
|
||||
export interface MfaEnableRequest {
|
||||
secret: string;
|
||||
verificationCode: string;
|
||||
}
|
||||
|
||||
export interface MfaEnableResponse {
|
||||
success: boolean;
|
||||
backupCodes: string[];
|
||||
}
|
||||
|
||||
export interface MfaDisableRequest {
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
export interface MfaBackupCodesResponse {
|
||||
backupCodes: string[];
|
||||
}
|
||||
|
||||
export interface MfaAccessValidationResponse {
|
||||
valid: boolean;
|
||||
isTransferToken?: boolean;
|
||||
requiresMfaSetup?: boolean;
|
||||
userHasMfa?: boolean;
|
||||
isMfaEnforced?: boolean;
|
||||
}
|
||||
66
apps/client/src/ee/security/components/enforce-mfa.tsx
Normal file
66
apps/client/src/ee/security/components/enforce-mfa.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { Group, Text, Switch, MantineSize, Title } 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 EnforceMfa() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title order={4} my="sm">
|
||||
MFA
|
||||
</Title>
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<Text size="md">{t("Enforce two-factor authentication")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"Once enforced, all members must enable two-factor authentication to access the workspace.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<EnforceMfaToggle />
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface EnforceMfaToggleProps {
|
||||
size?: MantineSize;
|
||||
label?: string;
|
||||
}
|
||||
export function EnforceMfaToggle({ size, label }: EnforceMfaToggleProps) {
|
||||
const { t } = useTranslation();
|
||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||
const [checked, setChecked] = useState(workspace?.enforceMfa);
|
||||
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.checked;
|
||||
try {
|
||||
const updatedWorkspace = await updateWorkspace({ enforceMfa: 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 MFA enforcement")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -11,6 +11,7 @@ import AllowedDomains from "@/ee/security/components/allowed-domains.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLicense from "@/ee/hooks/use-license.tsx";
|
||||
import usePlan from "@/ee/hooks/use-plan.tsx";
|
||||
import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
|
||||
|
||||
export default function Security() {
|
||||
const { t } = useTranslation();
|
||||
@ -33,6 +34,10 @@ export default function Security() {
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
<EnforceMfa />
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
<Title order={4} my="lg">
|
||||
Single sign-on (SSO)
|
||||
</Title>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import * as z from "zod";
|
||||
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import { useForm } from "@mantine/form";
|
||||
import {
|
||||
Container,
|
||||
Title,
|
||||
@ -11,6 +11,7 @@ import {
|
||||
Box,
|
||||
Stack,
|
||||
} from "@mantine/core";
|
||||
import { zodResolver } from "mantine-form-zod-resolver";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { IRegister } from "@/features/auth/types/auth.types";
|
||||
import useAuth from "@/features/auth/hooks/use-auth";
|
||||
|
||||
@ -27,7 +27,7 @@ import APP_ROUTE from "@/lib/app-route.ts";
|
||||
import { RESET } from "jotai/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
import { exchangeTokenRedirectUrl, getHostnameUrl } from "@/ee/utils.ts";
|
||||
import { exchangeTokenRedirectUrl } from "@/ee/utils.ts";
|
||||
|
||||
export default function useAuth() {
|
||||
const { t } = useTranslation();
|
||||
@ -39,9 +39,17 @@ export default function useAuth() {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await login(data);
|
||||
const response = await login(data);
|
||||
setIsLoading(false);
|
||||
navigate(APP_ROUTE.HOME);
|
||||
|
||||
// Check if MFA is required
|
||||
if (response?.userHasMfa) {
|
||||
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
|
||||
} else if (response?.requiresMfaSetup) {
|
||||
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
|
||||
} else {
|
||||
navigate(APP_ROUTE.HOME);
|
||||
}
|
||||
} catch (err) {
|
||||
setIsLoading(false);
|
||||
console.log(err);
|
||||
@ -56,9 +64,19 @@ export default function useAuth() {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await acceptInvitation(data);
|
||||
const response = await acceptInvitation(data);
|
||||
setIsLoading(false);
|
||||
navigate(APP_ROUTE.HOME);
|
||||
|
||||
if (response?.requiresLogin) {
|
||||
notifications.show({
|
||||
message: t(
|
||||
"Account created successfully. Please log in to set up two-factor authentication.",
|
||||
),
|
||||
});
|
||||
navigate(APP_ROUTE.AUTH.LOGIN);
|
||||
} else {
|
||||
navigate(APP_ROUTE.HOME);
|
||||
}
|
||||
} catch (err) {
|
||||
setIsLoading(false);
|
||||
notifications.show({
|
||||
@ -100,12 +118,22 @@ export default function useAuth() {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await passwordReset(data);
|
||||
const response = await passwordReset(data);
|
||||
setIsLoading(false);
|
||||
navigate(APP_ROUTE.HOME);
|
||||
notifications.show({
|
||||
message: t("Password reset was successful"),
|
||||
});
|
||||
|
||||
if (response?.requiresLogin) {
|
||||
notifications.show({
|
||||
message: t(
|
||||
"Password reset was successful. Please log in with your new password.",
|
||||
),
|
||||
});
|
||||
navigate(APP_ROUTE.AUTH.LOGIN);
|
||||
} else {
|
||||
navigate(APP_ROUTE.HOME);
|
||||
notifications.show({
|
||||
message: t("Password reset was successful"),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setIsLoading(false);
|
||||
notifications.show({
|
||||
|
||||
@ -4,14 +4,16 @@ import {
|
||||
ICollabToken,
|
||||
IForgotPassword,
|
||||
ILogin,
|
||||
ILoginResponse,
|
||||
IPasswordReset,
|
||||
ISetupWorkspace,
|
||||
IVerifyUserToken,
|
||||
} from "@/features/auth/types/auth.types";
|
||||
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
|
||||
|
||||
export async function login(data: ILogin): Promise<void> {
|
||||
await api.post<void>("/auth/login", data);
|
||||
export async function login(data: ILogin): Promise<ILoginResponse> {
|
||||
const response = await api.post<ILoginResponse>("/auth/login", data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
@ -36,8 +38,9 @@ export async function forgotPassword(data: IForgotPassword): Promise<void> {
|
||||
await api.post<void>("/auth/forgot-password", data);
|
||||
}
|
||||
|
||||
export async function passwordReset(data: IPasswordReset): Promise<void> {
|
||||
await api.post<void>("/auth/password-reset", data);
|
||||
export async function passwordReset(data: IPasswordReset): Promise<{ requiresLogin?: boolean; }> {
|
||||
const req = await api.post("/auth/password-reset", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function verifyUserToken(data: IVerifyUserToken): Promise<any> {
|
||||
@ -47,4 +50,4 @@ export async function verifyUserToken(data: IVerifyUserToken): Promise<any> {
|
||||
export async function getCollabToken(): Promise<ICollabToken> {
|
||||
const req = await api.post<ICollabToken>("/auth/collab-token");
|
||||
return req.data;
|
||||
}
|
||||
}
|
||||
@ -38,3 +38,10 @@ export interface IVerifyUserToken {
|
||||
export interface ICollabToken {
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export interface ILoginResponse {
|
||||
userHasMfa?: boolean;
|
||||
requiresMfaSetup?: boolean;
|
||||
mfaToken?: string;
|
||||
isMfaEnforced?: boolean;
|
||||
}
|
||||
|
||||
@ -4,10 +4,14 @@ import mermaid from "mermaid";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import classes from "./code-block.module.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useComputedColorScheme } from "@mantine/core";
|
||||
|
||||
const computedColorScheme = useComputedColorScheme();
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
suppressErrorRendering: true,
|
||||
theme: computedColorScheme === "light" ? "default" : "dark",
|
||||
});
|
||||
|
||||
interface MermaidViewProps {
|
||||
|
||||
@ -0,0 +1,96 @@
|
||||
.wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.resizing {
|
||||
user-select: none;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 10;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.resizeHandleBottom {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 24px;
|
||||
cursor: ns-resize;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
touch-action: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
|
||||
@mixin light {
|
||||
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.05)
|
||||
);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@mixin light {
|
||||
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.1)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper:hover .resizeHandleBottom,
|
||||
.resizing .resizeHandleBottom {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.resizeBar {
|
||||
width: 50px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
@mixin light {
|
||||
background-color: var(--mantine-color-gray-5);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background-color: var(--mantine-color-gray-6);
|
||||
}
|
||||
}
|
||||
|
||||
.resizeHandleBottom:hover .resizeBar,
|
||||
.resizing .resizeBar {
|
||||
@mixin light {
|
||||
background-color: var(--mantine-color-gray-7);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background-color: var(--mantine-color-gray-4);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,112 @@
|
||||
import React, { ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import classes from "./resizable-wrapper.module.css";
|
||||
|
||||
interface ResizableWrapperProps {
|
||||
children: ReactNode;
|
||||
initialHeight?: number;
|
||||
minHeight?: number;
|
||||
maxHeight?: number;
|
||||
onResize?: (height: number) => void;
|
||||
isEditable?: boolean;
|
||||
className?: string;
|
||||
showHandles?: "always" | "hover";
|
||||
direction?: "vertical" | "horizontal" | "both";
|
||||
}
|
||||
|
||||
export const ResizableWrapper: React.FC<ResizableWrapperProps> = ({
|
||||
children,
|
||||
initialHeight = 480,
|
||||
minHeight = 200,
|
||||
maxHeight = 1200,
|
||||
onResize,
|
||||
isEditable = true,
|
||||
className,
|
||||
showHandles = "hover",
|
||||
direction = "vertical",
|
||||
}) => {
|
||||
const [resizeParams, setResizeParams] = useState<{
|
||||
initialSize: number;
|
||||
initialClientY: number;
|
||||
initialClientX: number;
|
||||
} | null>(null);
|
||||
const [currentHeight, setCurrentHeight] = useState(initialHeight);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resizeParams) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!wrapperRef.current) return;
|
||||
|
||||
if (direction === "vertical" || direction === "both") {
|
||||
const deltaY = e.clientY - resizeParams.initialClientY;
|
||||
const newHeight = Math.min(
|
||||
Math.max(resizeParams.initialSize + deltaY, minHeight),
|
||||
maxHeight
|
||||
);
|
||||
setCurrentHeight(newHeight);
|
||||
wrapperRef.current.style.height = `${newHeight}px`;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setResizeParams(null);
|
||||
if (onResize && currentHeight !== initialHeight) {
|
||||
onResize(currentHeight);
|
||||
}
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [resizeParams, currentHeight, initialHeight, onResize, minHeight, maxHeight, direction]);
|
||||
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setResizeParams({
|
||||
initialSize: currentHeight,
|
||||
initialClientY: e.clientY,
|
||||
initialClientX: e.clientX,
|
||||
});
|
||||
|
||||
document.body.style.cursor = "ns-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
}, [currentHeight]);
|
||||
|
||||
const shouldShowHandles =
|
||||
isEditable &&
|
||||
(showHandles === "always" || (showHandles === "hover" && (isHovered || resizeParams)));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={clsx(classes.wrapper, className, {
|
||||
[classes.resizing]: !!resizeParams,
|
||||
})}
|
||||
style={{ height: currentHeight }}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{children}
|
||||
{!!resizeParams && <div className={classes.overlay} />}
|
||||
{shouldShowHandles && direction === "vertical" && (
|
||||
<div
|
||||
className={classes.resizeHandleBottom}
|
||||
onMouseDown={handleResizeStart}
|
||||
>
|
||||
<div className={classes.resizeBar} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,16 @@
|
||||
.embedWrapper {
|
||||
@mixin light {
|
||||
background-color: var(--mantine-color-gray-0);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background-color: var(--mantine-color-dark-7);
|
||||
}
|
||||
}
|
||||
|
||||
.embedIframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
}
|
||||
@ -1,9 +1,8 @@
|
||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { useMemo } from "react";
|
||||
import React, { useMemo, useCallback } from "react";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
ActionIcon,
|
||||
AspectRatio,
|
||||
Button,
|
||||
Card,
|
||||
FocusTrap,
|
||||
@ -14,7 +13,8 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { IconEdit } from "@tabler/icons-react";
|
||||
import { z } from "zod";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zodResolver } from "mantine-form-zod-resolver";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "i18next";
|
||||
@ -22,6 +22,8 @@ import {
|
||||
getEmbedProviderById,
|
||||
getEmbedUrlAndProvider,
|
||||
} from "@docmost/editor-ext";
|
||||
import { ResizableWrapper } from "../common/resizable-wrapper";
|
||||
import classes from "./embed-view.module.css";
|
||||
|
||||
const schema = z.object({
|
||||
url: z
|
||||
@ -33,7 +35,7 @@ const schema = z.object({
|
||||
export default function EmbedView(props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { node, selected, updateAttributes, editor } = props;
|
||||
const { src, provider } = node.attrs;
|
||||
const { src, provider, height: nodeHeight } = node.attrs;
|
||||
|
||||
const embedUrl = useMemo(() => {
|
||||
if (src) {
|
||||
@ -49,6 +51,10 @@ export default function EmbedView(props: NodeViewProps) {
|
||||
validate: zodResolver(schema),
|
||||
});
|
||||
|
||||
const handleResize = useCallback((newHeight: number) => {
|
||||
updateAttributes({ height: newHeight });
|
||||
}, [updateAttributes]);
|
||||
|
||||
async function onSubmit(data: { url: string }) {
|
||||
if (!editor.isEditable) {
|
||||
return;
|
||||
@ -77,17 +83,25 @@ export default function EmbedView(props: NodeViewProps) {
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
{embedUrl ? (
|
||||
<>
|
||||
<AspectRatio ratio={16 / 9}>
|
||||
<iframe
|
||||
src={embedUrl}
|
||||
allow="encrypted-media"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
allowFullScreen
|
||||
frameBorder="0"
|
||||
></iframe>
|
||||
</AspectRatio>
|
||||
</>
|
||||
<ResizableWrapper
|
||||
initialHeight={nodeHeight || 480}
|
||||
minHeight={200}
|
||||
maxHeight={1200}
|
||||
onResize={handleResize}
|
||||
isEditable={editor.isEditable}
|
||||
className={clsx(classes.embedWrapper, {
|
||||
"ProseMirror-selectednode": selected,
|
||||
})}
|
||||
>
|
||||
<iframe
|
||||
className={classes.embedIframe}
|
||||
src={embedUrl}
|
||||
allow="encrypted-media"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
allowFullScreen
|
||||
frameBorder="0"
|
||||
/>
|
||||
</ResizableWrapper>
|
||||
) : (
|
||||
<Popover
|
||||
width={300}
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
import { atom } from "jotai";
|
||||
|
||||
type SearchAndReplaceAtomType = {
|
||||
isOpen: boolean;
|
||||
};
|
||||
|
||||
export const searchAndReplaceStateAtom = atom<SearchAndReplaceAtomType>({
|
||||
isOpen: false,
|
||||
});
|
||||
@ -0,0 +1,312 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Dialog,
|
||||
Flex,
|
||||
Input,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconArrowNarrowDown,
|
||||
IconArrowNarrowUp,
|
||||
IconLetterCase,
|
||||
IconReplace,
|
||||
IconSearch,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useEditor } from "@tiptap/react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts";
|
||||
import { useAtom } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getHotkeyHandler, useToggle } from "@mantine/hooks";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import classes from "./search-replace.module.css";
|
||||
|
||||
interface PageFindDialogDialogProps {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [replaceText, setReplaceText] = useState("");
|
||||
const [pageFindState, setPageFindState] = useAtom(searchAndReplaceStateAtom);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const [replaceButton, replaceButtonToggle] = useToggle([
|
||||
{ isReplaceShow: false, color: "gray" },
|
||||
{ isReplaceShow: true, color: "blue" },
|
||||
]);
|
||||
|
||||
const [caseSensitive, caseSensitiveToggle] = useToggle([
|
||||
{ isCaseSensitive: false, color: "gray" },
|
||||
{ isCaseSensitive: true, color: "blue" },
|
||||
]);
|
||||
|
||||
const searchInputEvent = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchText(event.target.value);
|
||||
};
|
||||
|
||||
const replaceInputEvent = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setReplaceText(event.target.value);
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setSearchText("");
|
||||
setReplaceText("");
|
||||
setPageFindState({ isOpen: false });
|
||||
// Reset replace button state when closing
|
||||
if (replaceButton.isReplaceShow) {
|
||||
replaceButtonToggle();
|
||||
}
|
||||
// Clear search term in editor
|
||||
if (editor) {
|
||||
editor.commands.setSearchTerm("");
|
||||
}
|
||||
};
|
||||
|
||||
const goToSelection = () => {
|
||||
if (!editor) return;
|
||||
|
||||
const { results, resultIndex } = editor.storage.searchAndReplace;
|
||||
const position: Range = results[resultIndex];
|
||||
|
||||
if (!position) return;
|
||||
|
||||
// @ts-ignore
|
||||
editor.commands.setTextSelection(position);
|
||||
|
||||
const element = document.querySelector(".search-result-current");
|
||||
if (element)
|
||||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
|
||||
editor.commands.setTextSelection(0);
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
editor.commands.nextSearchResult();
|
||||
goToSelection();
|
||||
};
|
||||
|
||||
const previous = () => {
|
||||
editor.commands.previousSearchResult();
|
||||
goToSelection();
|
||||
};
|
||||
|
||||
const replace = () => {
|
||||
editor.commands.setReplaceTerm(replaceText);
|
||||
editor.commands.replace();
|
||||
goToSelection();
|
||||
};
|
||||
|
||||
const replaceAll = () => {
|
||||
editor.commands.setReplaceTerm(replaceText);
|
||||
editor.commands.replaceAll();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
editor.commands.setSearchTerm(searchText);
|
||||
editor.commands.resetIndex();
|
||||
editor.commands.selectCurrentItem();
|
||||
}, [searchText]);
|
||||
|
||||
const handleOpenEvent = (e) => {
|
||||
setPageFindState({ isOpen: true });
|
||||
const selectedText = editor.state.doc.textBetween(
|
||||
editor.state.selection.from,
|
||||
editor.state.selection.to,
|
||||
);
|
||||
if (selectedText !== "") {
|
||||
setSearchText(selectedText);
|
||||
}
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
};
|
||||
|
||||
const handleCloseEvent = (e) => {
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
!pageFindState.isOpen && closeDialog();
|
||||
|
||||
document.addEventListener("openFindDialogFromEditor", handleOpenEvent);
|
||||
document.addEventListener("closeFindDialogFromEditor", handleCloseEvent);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("openFindDialogFromEditor", handleOpenEvent);
|
||||
document.removeEventListener(
|
||||
"closeFindDialogFromEditor",
|
||||
handleCloseEvent,
|
||||
);
|
||||
};
|
||||
}, [pageFindState.isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
editor.commands.setCaseSensitive(caseSensitive.isCaseSensitive);
|
||||
editor.commands.resetIndex();
|
||||
goToSelection();
|
||||
}, [caseSensitive]);
|
||||
|
||||
const resultsCount = useMemo(
|
||||
() =>
|
||||
searchText.trim() === ""
|
||||
? ""
|
||||
: editor?.storage?.searchAndReplace?.results.length > 0
|
||||
? editor?.storage?.searchAndReplace?.resultIndex +
|
||||
1 +
|
||||
"/" +
|
||||
editor?.storage?.searchAndReplace?.results.length
|
||||
: t("Not found"),
|
||||
[
|
||||
searchText,
|
||||
editor?.storage?.searchAndReplace?.resultIndex,
|
||||
editor?.storage?.searchAndReplace?.results.length,
|
||||
],
|
||||
);
|
||||
|
||||
const location = useLocation();
|
||||
useEffect(() => {
|
||||
closeDialog();
|
||||
}, [location]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
className={classes.findDialog}
|
||||
opened={pageFindState.isOpen}
|
||||
|
||||
size="lg"
|
||||
radius="md"
|
||||
w={"auto"}
|
||||
position={{ top: 90, right: 50 }}
|
||||
withBorder
|
||||
transitionProps={{ transition: "slide-down" }}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Flex align="center" gap="xs">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder={t("Find")}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
rightSection={
|
||||
<Text size="xs" ta="right">
|
||||
{resultsCount}
|
||||
</Text>
|
||||
}
|
||||
rightSectionWidth="70"
|
||||
rightSectionPointerEvents="all"
|
||||
size="xs"
|
||||
w={220}
|
||||
onChange={searchInputEvent}
|
||||
value={searchText}
|
||||
autoFocus
|
||||
onKeyDown={getHotkeyHandler([
|
||||
["Enter", next],
|
||||
["shift+Enter", previous],
|
||||
["alt+C", caseSensitiveToggle],
|
||||
//@ts-ignore
|
||||
...(editable ? [["alt+R", replaceButtonToggle]] : []),
|
||||
])}
|
||||
/>
|
||||
|
||||
<ActionIcon.Group>
|
||||
<Tooltip label={t("Previous match (Shift+Enter)")}>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={previous}>
|
||||
<IconArrowNarrowUp
|
||||
style={{ width: "70%", height: "70%" }}
|
||||
stroke={1.5}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("Next match (Enter)")}>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={next}>
|
||||
<IconArrowNarrowDown
|
||||
style={{ width: "70%", height: "70%" }}
|
||||
stroke={1.5}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("Match case (Alt+C)")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={caseSensitive.color}
|
||||
onClick={() => caseSensitiveToggle()}
|
||||
>
|
||||
<IconLetterCase
|
||||
style={{ width: "70%", height: "70%" }}
|
||||
stroke={1.5}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{editable && (
|
||||
<Tooltip label={t("Replace")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={replaceButton.color}
|
||||
onClick={() => replaceButtonToggle()}
|
||||
>
|
||||
<IconReplace
|
||||
style={{ width: "70%", height: "70%" }}
|
||||
stroke={1.5}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={t("Close (Escape)")}>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={closeDialog}>
|
||||
<IconX style={{ width: "70%", height: "70%" }} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</ActionIcon.Group>
|
||||
</Flex>
|
||||
{replaceButton.isReplaceShow && editable && (
|
||||
<Flex align="center" gap="xs">
|
||||
<Input
|
||||
placeholder={t("Replace")}
|
||||
leftSection={<IconReplace size={16} />}
|
||||
rightSection={<div></div>}
|
||||
rightSectionPointerEvents="all"
|
||||
size="xs"
|
||||
w={180}
|
||||
autoFocus
|
||||
onChange={replaceInputEvent}
|
||||
value={replaceText}
|
||||
onKeyDown={getHotkeyHandler([
|
||||
["Enter", replace],
|
||||
["ctrl+alt+Enter", replaceAll],
|
||||
])}
|
||||
/>
|
||||
<ActionIcon.Group>
|
||||
<Tooltip label={t("Replace (Enter)")}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={replace}
|
||||
>
|
||||
{t("Replace")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("Replace all (Ctrl+Alt+Enter)")}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={replaceAll}
|
||||
>
|
||||
{t("Replace all")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</ActionIcon.Group>
|
||||
</Flex>
|
||||
)}
|
||||
</Stack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default SearchAndReplaceDialog;
|
||||
@ -0,0 +1,10 @@
|
||||
.findDialog{
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.findDialog div[data-position="right"].mantine-Input-section {
|
||||
justify-content: right;
|
||||
padding-right: 8px;
|
||||
}
|
||||
@ -0,0 +1,145 @@
|
||||
import React, { FC } from "react";
|
||||
import { IconCheck, IconPalette } from "@tabler/icons-react";
|
||||
import {
|
||||
ActionIcon,
|
||||
ColorSwatch,
|
||||
Popover,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { useEditor } from "@tiptap/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface TableColorItem {
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface TableBackgroundColorProps {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
}
|
||||
|
||||
const TABLE_COLORS: TableColorItem[] = [
|
||||
{ name: "Default", color: "" },
|
||||
{ name: "Blue", color: "#b4d5ff" },
|
||||
{ name: "Green", color: "#acf5d2" },
|
||||
{ name: "Yellow", color: "#fef1b4" },
|
||||
{ name: "Red", color: "#ffbead" },
|
||||
{ name: "Pink", color: "#ffc7fe" },
|
||||
{ name: "Gray", color: "#eaecef" },
|
||||
{ name: "Purple", color: "#c1b7f2" },
|
||||
];
|
||||
|
||||
export const TableBackgroundColor: FC<TableBackgroundColorProps> = ({
|
||||
editor,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [opened, setOpened] = React.useState(false);
|
||||
|
||||
const setTableCellBackground = (color: string, colorName: string) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.updateAttributes("tableCell", {
|
||||
backgroundColor: color || null,
|
||||
backgroundColorName: color ? colorName : null
|
||||
})
|
||||
.updateAttributes("tableHeader", {
|
||||
backgroundColor: color || null,
|
||||
backgroundColorName: color ? colorName : null
|
||||
})
|
||||
.run();
|
||||
setOpened(false);
|
||||
};
|
||||
|
||||
// Get current cell's background color
|
||||
const getCurrentColor = () => {
|
||||
if (editor.isActive("tableCell")) {
|
||||
const attrs = editor.getAttributes("tableCell");
|
||||
return attrs.backgroundColor || "";
|
||||
}
|
||||
if (editor.isActive("tableHeader")) {
|
||||
const attrs = editor.getAttributes("tableHeader");
|
||||
return attrs.backgroundColor || "";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const currentColor = getCurrentColor();
|
||||
|
||||
return (
|
||||
<Popover
|
||||
width={200}
|
||||
position="bottom"
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
withArrow
|
||||
transitionProps={{ transition: "pop" }}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Tooltip label={t("Background color")} withArrow>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
aria-label={t("Background color")}
|
||||
onClick={() => setOpened(!opened)}
|
||||
>
|
||||
<IconPalette size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Background color")}
|
||||
</Text>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(4, 1fr)",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
{TABLE_COLORS.map((item, index) => (
|
||||
<UnstyledButton
|
||||
key={index}
|
||||
onClick={() => setTableCellBackground(item.color, item.name)}
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
}}
|
||||
title={t(item.name)}
|
||||
>
|
||||
<ColorSwatch
|
||||
color={item.color || "#ffffff"}
|
||||
size={24}
|
||||
style={{
|
||||
border: item.color === "" ? "1px solid #e5e7eb" : undefined,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{currentColor === item.color && (
|
||||
<IconCheck
|
||||
size={18}
|
||||
style={{
|
||||
color:
|
||||
item.color === "" || item.color.startsWith("#F")
|
||||
? "#000000"
|
||||
: "#ffffff",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ColorSwatch>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
</div>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@ -12,8 +12,11 @@ import {
|
||||
IconColumnRemove,
|
||||
IconRowRemove,
|
||||
IconSquareToggle,
|
||||
IconTableRow,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TableBackgroundColor } from "./table-background-color";
|
||||
import { TableTextAlignment } from "./table-text-alignment";
|
||||
|
||||
export const TableCellMenu = React.memo(
|
||||
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
|
||||
@ -45,6 +48,10 @@ export const TableCellMenu = React.memo(
|
||||
editor.chain().focus().deleteRow().run();
|
||||
}, [editor]);
|
||||
|
||||
const toggleHeaderCell = useCallback(() => {
|
||||
editor.chain().focus().toggleHeaderCell().run();
|
||||
}, [editor]);
|
||||
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
@ -60,6 +67,9 @@ export const TableCellMenu = React.memo(
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<ActionIcon.Group>
|
||||
<TableBackgroundColor editor={editor} />
|
||||
<TableTextAlignment editor={editor} />
|
||||
|
||||
<Tooltip position="top" label={t("Merge cells")}>
|
||||
<ActionIcon
|
||||
onClick={mergeCells}
|
||||
@ -103,6 +113,17 @@ export const TableCellMenu = React.memo(
|
||||
<IconRowRemove size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Toggle header cell")}>
|
||||
<ActionIcon
|
||||
onClick={toggleHeaderCell}
|
||||
variant="default"
|
||||
size="lg"
|
||||
aria-label={t("Toggle header cell")}
|
||||
>
|
||||
<IconTableRow size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</ActionIcon.Group>
|
||||
</BaseBubbleMenu>
|
||||
);
|
||||
|
||||
@ -0,0 +1,109 @@
|
||||
import React, { FC } from "react";
|
||||
import {
|
||||
IconAlignCenter,
|
||||
IconAlignLeft,
|
||||
IconAlignRight,
|
||||
IconCheck,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Popover,
|
||||
rem,
|
||||
ScrollArea,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useEditor } from "@tiptap/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface TableTextAlignmentProps {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
}
|
||||
|
||||
interface AlignmentItem {
|
||||
name: string;
|
||||
icon: React.ElementType;
|
||||
command: () => void;
|
||||
isActive: () => boolean;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
|
||||
const { t } = useTranslation();
|
||||
const [opened, setOpened] = React.useState(false);
|
||||
|
||||
const items: AlignmentItem[] = [
|
||||
{
|
||||
name: "Align left",
|
||||
value: "left",
|
||||
isActive: () => editor.isActive({ textAlign: "left" }),
|
||||
command: () => editor.chain().focus().setTextAlign("left").run(),
|
||||
icon: IconAlignLeft,
|
||||
},
|
||||
{
|
||||
name: "Align center",
|
||||
value: "center",
|
||||
isActive: () => editor.isActive({ textAlign: "center" }),
|
||||
command: () => editor.chain().focus().setTextAlign("center").run(),
|
||||
icon: IconAlignCenter,
|
||||
},
|
||||
{
|
||||
name: "Align right",
|
||||
value: "right",
|
||||
isActive: () => editor.isActive({ textAlign: "right" }),
|
||||
command: () => editor.chain().focus().setTextAlign("right").run(),
|
||||
icon: IconAlignRight,
|
||||
},
|
||||
];
|
||||
|
||||
const activeItem = items.find((item) => item.isActive()) || items[0];
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
position="bottom"
|
||||
withArrow
|
||||
transitionProps={{ transition: 'pop' }}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Tooltip label={t("Text alignment")} withArrow>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
aria-label={t("Text alignment")}
|
||||
onClick={() => setOpened(!opened)}
|
||||
>
|
||||
<activeItem.icon size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown>
|
||||
<ScrollArea.Autosize type="scroll" mah={300}>
|
||||
<Button.Group orientation="vertical">
|
||||
{items.map((item, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="default"
|
||||
leftSection={<item.icon size={16} />}
|
||||
rightSection={
|
||||
item.isActive() && <IconCheck size={16} />
|
||||
}
|
||||
justify="left"
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
item.command();
|
||||
setOpened(false);
|
||||
}}
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
{t(item.name)}
|
||||
</Button>
|
||||
))}
|
||||
</Button.Group>
|
||||
</ScrollArea.Autosize>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@ -11,7 +11,6 @@ import { Typography } from "@tiptap/extension-typography";
|
||||
import { TextStyle } from "@tiptap/extension-text-style";
|
||||
import { Color } from "@tiptap/extension-color";
|
||||
import Table from "@tiptap/extension-table";
|
||||
import TableHeader from "@tiptap/extension-table-header";
|
||||
import SlashCommand from "@/features/editor/extensions/slash-command";
|
||||
import { Collaboration } from "@tiptap/extension-collaboration";
|
||||
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
|
||||
@ -25,6 +24,7 @@ import {
|
||||
MathInline,
|
||||
TableCell,
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TrailingNode,
|
||||
TiptapImage,
|
||||
Callout,
|
||||
@ -36,6 +36,7 @@ import {
|
||||
Drawio,
|
||||
Excalidraw,
|
||||
Embed,
|
||||
SearchAndReplace,
|
||||
Mention,
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
@ -217,6 +218,22 @@ export const mainExtensions = [
|
||||
CharacterCount.configure({
|
||||
wordCounter: (text) => countWords(text),
|
||||
}),
|
||||
SearchAndReplace.extend({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
'Mod-f': () => {
|
||||
const event = new CustomEvent("openFindDialogFromEditor", {});
|
||||
document.dispatchEvent(event);
|
||||
return true;
|
||||
},
|
||||
'Escape': () => {
|
||||
const event = new CustomEvent("closeFindDialogFromEditor", {});
|
||||
document.dispatchEvent(event);
|
||||
return true;
|
||||
},
|
||||
}
|
||||
},
|
||||
}).configure(),
|
||||
] as any;
|
||||
|
||||
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
||||
|
||||
@ -39,6 +39,7 @@ import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
|
||||
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
|
||||
import DrawioMenu from "./components/drawio/drawio-menu";
|
||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
|
||||
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
|
||||
import { useIdle } from "@/hooks/use-idle.ts";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
@ -125,7 +126,15 @@ export default function PageEditor({
|
||||
const now = Date.now().valueOf() / 1000;
|
||||
const isTokenExpired = now >= payload.exp;
|
||||
if (isTokenExpired) {
|
||||
refetchCollabToken();
|
||||
refetchCollabToken().then((result) => {
|
||||
if (result.data?.token) {
|
||||
remote.disconnect();
|
||||
setTimeout(() => {
|
||||
remote.configuration.token = result.data.token;
|
||||
remote.connect();
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
onStatus: (status) => {
|
||||
@ -151,6 +160,21 @@ export default function PageEditor({
|
||||
};
|
||||
}, [pageId]);
|
||||
|
||||
/*
|
||||
useEffect(() => {
|
||||
// Handle token updates by reconnecting with new token
|
||||
if (providersRef.current?.remote && collabQuery?.token) {
|
||||
const currentToken = providersRef.current.remote.configuration.token;
|
||||
if (currentToken !== collabQuery.token) {
|
||||
// Token has changed, need to reconnect with new token
|
||||
providersRef.current.remote.disconnect();
|
||||
providersRef.current.remote.configuration.token = collabQuery.token;
|
||||
providersRef.current.remote.connect();
|
||||
}
|
||||
}
|
||||
}, [collabQuery?.token]);
|
||||
*/
|
||||
|
||||
// Only connect/disconnect on tab/idle, not destroy
|
||||
useEffect(() => {
|
||||
if (!providersReady || !providersRef.current) return;
|
||||
@ -193,6 +217,10 @@ export default function PageEditor({
|
||||
scrollMargin: 80,
|
||||
handleDOMEvents: {
|
||||
keydown: (_view, event) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyS') {
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
|
||||
const slashCommand = document.querySelector("#slash-command");
|
||||
if (slashCommand) {
|
||||
@ -350,6 +378,11 @@ export default function PageEditor({
|
||||
<div style={{ position: "relative" }}>
|
||||
<div ref={menuContainerRef}>
|
||||
<EditorContent editor={editor} />
|
||||
|
||||
{editor && (
|
||||
<SearchAndReplaceDialog editor={editor} editable={editable} />
|
||||
)}
|
||||
|
||||
{editor && editor.isEditable && (
|
||||
<div>
|
||||
<EditorBubbleMenu editor={editor} />
|
||||
|
||||
@ -71,4 +71,12 @@
|
||||
[data-type="details"][open] > [data-type="detailsButton"] .ProseMirror-icon{
|
||||
transform: rotateZ(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
[data-type="details"]:has(.search-result) > [data-type="detailsContainer"] > [data-type="detailsContent"]{
|
||||
display: block;
|
||||
}
|
||||
|
||||
[data-type="details"]:has(.search-result) > [data-type="detailsButton"] .ProseMirror-icon{
|
||||
transform: rotateZ(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
9
apps/client/src/features/editor/styles/find.css
Normal file
9
apps/client/src/features/editor/styles/find.css
Normal file
@ -0,0 +1,9 @@
|
||||
.search-result{
|
||||
background: #ffff65;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.search-result-current{
|
||||
background: #ffc266 !important;
|
||||
color: #212529;
|
||||
}
|
||||
@ -9,5 +9,6 @@
|
||||
@import "./media.css";
|
||||
@import "./code.css";
|
||||
@import "./print.css";
|
||||
@import "./find.css";
|
||||
@import "./mention.css";
|
||||
|
||||
@import "./ordered-list.css";
|
||||
|
||||
34
apps/client/src/features/editor/styles/ordered-list.css
Normal file
34
apps/client/src/features/editor/styles/ordered-list.css
Normal file
@ -0,0 +1,34 @@
|
||||
/* Ordered list type cycling based on nesting depth */
|
||||
ol,
|
||||
ol ol ol ol,
|
||||
ol ol ol ol ol ol ol,
|
||||
ol ol ol ol ol ol ol ol ol ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
ol ol,
|
||||
ol ol ol ol ol,
|
||||
ol ol ol ol ol ol ol ol,
|
||||
ol ol ol ol ol ol ol ol ol ol ol {
|
||||
list-style-type: lower-alpha;
|
||||
}
|
||||
|
||||
ol ol ol,
|
||||
ol ol ol ol ol ol,
|
||||
ol ol ol ol ol ol ol ol ol,
|
||||
ol ol ol ol ol ol ol ol ol ol ol ol {
|
||||
list-style-type: lower-roman;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style-position: outside;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* Nested list spacing */
|
||||
ol ol,
|
||||
ol ul,
|
||||
ul ol {
|
||||
margin-top: 0.1rem;
|
||||
margin-bottom: 0.1rem;
|
||||
}
|
||||
@ -4,6 +4,7 @@
|
||||
overflow-x: auto;
|
||||
& table {
|
||||
overflow-x: hidden;
|
||||
min-width: 700px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@ -38,8 +39,8 @@
|
||||
|
||||
th {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-5)
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-5)
|
||||
);
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
@ -66,8 +67,54 @@
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/* Table cell background colors with dark mode support */
|
||||
.ProseMirror {
|
||||
table {
|
||||
@mixin dark {
|
||||
/* Blue */
|
||||
td[data-background-color="#b4d5ff"],
|
||||
th[data-background-color="#b4d5ff"] {
|
||||
background-color: #1a3a5c !important;
|
||||
}
|
||||
|
||||
/* Green */
|
||||
td[data-background-color="#acf5d2"],
|
||||
th[data-background-color="#acf5d2"] {
|
||||
background-color: #1a4d3a !important;
|
||||
}
|
||||
|
||||
/* Yellow */
|
||||
td[data-background-color="#fef1b4"],
|
||||
th[data-background-color="#fef1b4"] {
|
||||
background-color: #7c5014 !important;
|
||||
}
|
||||
|
||||
/* Red */
|
||||
td[data-background-color="#ffbead"],
|
||||
th[data-background-color="#ffbead"] {
|
||||
background-color: #5c2a23 !important;
|
||||
}
|
||||
|
||||
/* Pink */
|
||||
td[data-background-color="#ffc7fe"],
|
||||
th[data-background-color="#ffc7fe"] {
|
||||
background-color: #4d2a4d !important;
|
||||
}
|
||||
|
||||
/* Gray */
|
||||
td[data-background-color="#eaecef"],
|
||||
th[data-background-color="#eaecef"] {
|
||||
background-color: #2a2e33 !important;
|
||||
}
|
||||
|
||||
/* Purple */
|
||||
td[data-background-color="#c1b7f2"],
|
||||
th[data-background-color="#c1b7f2"] {
|
||||
background-color: #3a2f5c !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,8 +10,11 @@ import {
|
||||
pageEditorAtom,
|
||||
titleEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms";
|
||||
import { updatePageData, useUpdateTitlePageMutation } from "@/features/page/queries/page-query";
|
||||
import { useDebouncedCallback } from "@mantine/hooks";
|
||||
import {
|
||||
updatePageData,
|
||||
useUpdateTitlePageMutation,
|
||||
} from "@/features/page/queries/page-query";
|
||||
import { useDebouncedCallback, getHotkeyHandler } from "@mantine/hooks";
|
||||
import { useAtom } from "jotai";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||
import { History } from "@tiptap/extension-history";
|
||||
@ -40,7 +43,8 @@ export function TitleEditor({
|
||||
editable,
|
||||
}: TitleEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { mutateAsync: updateTitlePageMutationAsync } = useUpdateTitlePageMutation();
|
||||
const { mutateAsync: updateTitlePageMutationAsync } =
|
||||
useUpdateTitlePageMutation();
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
||||
const emit = useQueryEmit();
|
||||
@ -108,7 +112,12 @@ export function TitleEditor({
|
||||
spaceId: page.spaceId,
|
||||
entity: ["pages"],
|
||||
id: page.id,
|
||||
payload: { title: page.title, slugId: page.slugId, parentPageId: page.parentPageId, icon: page.icon },
|
||||
payload: {
|
||||
title: page.title,
|
||||
slugId: page.slugId,
|
||||
parentPageId: page.parentPageId,
|
||||
icon: page.icon,
|
||||
},
|
||||
};
|
||||
|
||||
if (page.title !== titleEditor.getText()) return;
|
||||
@ -152,13 +161,19 @@ export function TitleEditor({
|
||||
}
|
||||
}, [userPageEditMode, titleEditor, editable]);
|
||||
|
||||
const openSearchDialog = () => {
|
||||
const event = new CustomEvent("openFindDialogFromEditor", {});
|
||||
document.dispatchEvent(event);
|
||||
};
|
||||
|
||||
function handleTitleKeyDown(event: any) {
|
||||
if (!titleEditor || !pageEditor || event.shiftKey) return;
|
||||
|
||||
// Prevent focus shift when IME composition is active
|
||||
|
||||
// Prevent focus shift when IME composition is active
|
||||
// `keyCode === 229` is added to support Safari where `isComposing` may not be reliable
|
||||
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return;
|
||||
|
||||
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229)
|
||||
return;
|
||||
|
||||
const { key } = event;
|
||||
const { $head } = titleEditor.state.selection;
|
||||
|
||||
@ -172,5 +187,16 @@ export function TitleEditor({
|
||||
}
|
||||
}
|
||||
|
||||
return <EditorContent editor={titleEditor} onKeyDown={handleTitleKeyDown} />;
|
||||
return (
|
||||
<EditorContent
|
||||
editor={titleEditor}
|
||||
onKeyDown={(event) => {
|
||||
// First handle the search hotkey
|
||||
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
|
||||
|
||||
// Then handle other key events
|
||||
handleTitleKeyDown(event);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Modal, Button, Group, Text } from "@mantine/core";
|
||||
import { copyPageToSpace } from "@/features/page/services/page-service.ts";
|
||||
import { duplicatePage } from "@/features/page/services/page-service.ts";
|
||||
import { useState } from "react";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -30,7 +30,7 @@ export default function CopyPageModal({
|
||||
if (!targetSpace) return;
|
||||
|
||||
try {
|
||||
const copiedPage = await copyPageToSpace({
|
||||
const copiedPage = await duplicatePage({
|
||||
pageId,
|
||||
spaceId: targetSpace.id,
|
||||
});
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
IconList,
|
||||
IconMessage,
|
||||
IconPrinter,
|
||||
IconSearch,
|
||||
IconTrash,
|
||||
IconWifiOff,
|
||||
} from "@tabler/icons-react";
|
||||
@ -16,7 +17,12 @@ import React from "react";
|
||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||
import { useClipboard, useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
getHotkeyHandler,
|
||||
useClipboard,
|
||||
useDisclosure,
|
||||
useHotkeys,
|
||||
} from "@mantine/hooks";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
@ -32,6 +38,7 @@ import {
|
||||
pageEditorAtom,
|
||||
yjsConnectionStatusAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts";
|
||||
import { formattedDate, timeAgo } from "@/lib/time.ts";
|
||||
import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
|
||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||
@ -46,6 +53,26 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||
const toggleAside = useToggleAside();
|
||||
const [yjsConnectionStatus] = useAtom(yjsConnectionStatusAtom);
|
||||
|
||||
useHotkeys(
|
||||
[
|
||||
[
|
||||
"mod+F",
|
||||
() => {
|
||||
const event = new CustomEvent("openFindDialogFromEditor", {});
|
||||
document.dispatchEvent(event);
|
||||
},
|
||||
],
|
||||
[
|
||||
"Escape",
|
||||
() => {
|
||||
const event = new CustomEvent("closeFindDialogFromEditor", {});
|
||||
document.dispatchEvent(event);
|
||||
},
|
||||
],
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{yjsConnectionStatus === "disconnected" && (
|
||||
|
||||
@ -42,8 +42,8 @@ export async function movePageToSpace(data: IMovePageToSpace): Promise<void> {
|
||||
await api.post<void>("/pages/move-to-space", data);
|
||||
}
|
||||
|
||||
export async function copyPageToSpace(data: ICopyPageToSpace): Promise<IPage> {
|
||||
const req = await api.post<IPage>("/pages/copy-to-space", data);
|
||||
export async function duplicatePage(data: ICopyPageToSpace): Promise<IPage> {
|
||||
const req = await api.post<IPage>("/pages/duplicate", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist";
|
||||
import {
|
||||
NodeApi,
|
||||
NodeRendererProps,
|
||||
Tree,
|
||||
TreeApi,
|
||||
SimpleTree,
|
||||
} from "react-arborist";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
||||
import {
|
||||
@ -66,6 +72,7 @@ import MovePageModal from "../../components/move-page-modal.tsx";
|
||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||
import CopyPageModal from "../../components/copy-page-modal.tsx";
|
||||
import { duplicatePage } from "../../services/page-service.ts";
|
||||
|
||||
interface SpaceTreeProps {
|
||||
spaceId: string;
|
||||
@ -90,8 +97,14 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||
const treeApiRef = useRef<TreeApi<SpaceTreeNode>>();
|
||||
const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>(openTreeNodesAtom);
|
||||
const rootElement = useRef<HTMLDivElement>();
|
||||
const [isRootReady, setIsRootReady] = useState(false);
|
||||
const { ref: sizeRef, width, height } = useElementSize();
|
||||
const mergedRef = useMergedRef(rootElement, sizeRef);
|
||||
const mergedRef = useMergedRef((element) => {
|
||||
rootElement.current = element;
|
||||
if (element && !isRootReady) {
|
||||
setIsRootReady(true);
|
||||
}
|
||||
}, sizeRef);
|
||||
const [isDataLoaded, setIsDataLoaded] = useState(false);
|
||||
const { data: currentPage } = usePageQuery({
|
||||
pageId: extractPageSlugId(pageSlug),
|
||||
@ -199,16 +212,17 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||
}
|
||||
}, [currentPage?.id]);
|
||||
|
||||
// Clean up tree API on unmount
|
||||
useEffect(() => {
|
||||
if (treeApiRef.current) {
|
||||
return () => {
|
||||
// @ts-ignore
|
||||
setTreeApi(treeApiRef.current);
|
||||
}
|
||||
}, [treeApiRef.current]);
|
||||
setTreeApi(null);
|
||||
};
|
||||
}, [setTreeApi]);
|
||||
|
||||
return (
|
||||
<div ref={mergedRef} className={classes.treeContainer}>
|
||||
{rootElement.current && (
|
||||
{isRootReady && rootElement.current && (
|
||||
<Tree
|
||||
data={data.filter((node) => node?.spaceId === spaceId)}
|
||||
disableDrag={readOnly}
|
||||
@ -217,7 +231,13 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||
{...controllers}
|
||||
width={width}
|
||||
height={rootElement.current.clientHeight}
|
||||
ref={treeApiRef}
|
||||
ref={(ref) => {
|
||||
treeApiRef.current = ref;
|
||||
if (ref) {
|
||||
//@ts-ignore
|
||||
setTreeApi(ref);
|
||||
}
|
||||
}}
|
||||
openByDefault={false}
|
||||
disableMultiSelection={true}
|
||||
className={classes.tree}
|
||||
@ -383,7 +403,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
||||
<span className={classes.text}>{node.data.name || t("untitled")}</span>
|
||||
|
||||
<div className={classes.actions}>
|
||||
<NodeMenu node={node} treeApi={tree} />
|
||||
<NodeMenu node={node} treeApi={tree} spaceId={node.data.spaceId} />
|
||||
|
||||
{!tree.props.disableEdit && (
|
||||
<CreateNode
|
||||
@ -436,13 +456,16 @@ function CreateNode({ node, treeApi, onExpandTree }: CreateNodeProps) {
|
||||
interface NodeMenuProps {
|
||||
node: NodeApi<SpaceTreeNode>;
|
||||
treeApi: TreeApi<SpaceTreeNode>;
|
||||
spaceId: string;
|
||||
}
|
||||
|
||||
function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||
function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const clipboard = useClipboard({ timeout: 500 });
|
||||
const { spaceSlug } = useParams();
|
||||
const { openDeleteModal } = useDeletePageModal();
|
||||
const [data, setData] = useAtom(treeDataAtom);
|
||||
const emit = useQueryEmit();
|
||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||
useDisclosure(false);
|
||||
const [
|
||||
@ -461,6 +484,68 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||
notifications.show({ message: t("Link copied") });
|
||||
};
|
||||
|
||||
const handleDuplicatePage = async () => {
|
||||
try {
|
||||
const duplicatedPage = await duplicatePage({
|
||||
pageId: node.id,
|
||||
});
|
||||
|
||||
// Find the index of the current node
|
||||
const parentId =
|
||||
node.parent?.id === "__REACT_ARBORIST_INTERNAL_ROOT__"
|
||||
? null
|
||||
: node.parent?.id;
|
||||
const siblings = parentId ? node.parent.children : treeApi?.props.data;
|
||||
const currentIndex =
|
||||
siblings?.findIndex((sibling) => sibling.id === node.id) || 0;
|
||||
const newIndex = currentIndex + 1;
|
||||
|
||||
// Add the duplicated page to the tree
|
||||
const treeNodeData: SpaceTreeNode = {
|
||||
id: duplicatedPage.id,
|
||||
slugId: duplicatedPage.slugId,
|
||||
name: duplicatedPage.title,
|
||||
position: duplicatedPage.position,
|
||||
spaceId: duplicatedPage.spaceId,
|
||||
parentPageId: duplicatedPage.parentPageId,
|
||||
icon: duplicatedPage.icon,
|
||||
hasChildren: duplicatedPage.hasChildren,
|
||||
children: [],
|
||||
};
|
||||
|
||||
// Update local tree
|
||||
const simpleTree = new SimpleTree(data);
|
||||
simpleTree.create({
|
||||
parentId,
|
||||
index: newIndex,
|
||||
data: treeNodeData,
|
||||
});
|
||||
setData(simpleTree.data);
|
||||
|
||||
// Emit socket event
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
operation: "addTreeNode",
|
||||
spaceId: spaceId,
|
||||
payload: {
|
||||
parentId,
|
||||
index: newIndex,
|
||||
data: treeNodeData,
|
||||
},
|
||||
});
|
||||
}, 50);
|
||||
|
||||
notifications.show({
|
||||
message: t("Page duplicated successfully"),
|
||||
});
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
message: err.response?.data.message || "An error occurred",
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu shadow="md" width={200}>
|
||||
@ -505,6 +590,17 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||
|
||||
{!(treeApi.props.disableEdit as boolean) && (
|
||||
<>
|
||||
<Menu.Item
|
||||
leftSection={<IconCopy size={16} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleDuplicatePage();
|
||||
}}
|
||||
>
|
||||
{t("Duplicate")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconArrowRight size={16} />}
|
||||
onClick={(e) => {
|
||||
@ -524,7 +620,7 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||
openCopyPageModal();
|
||||
}}
|
||||
>
|
||||
{t("Copy")}
|
||||
{t("Copy to space")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
@ -49,7 +49,7 @@ export interface IMovePageToSpace {
|
||||
|
||||
export interface ICopyPageToSpace {
|
||||
pageId: string;
|
||||
spaceId: string;
|
||||
spaceId?: string;
|
||||
}
|
||||
|
||||
export interface SidebarPagesParams {
|
||||
|
||||
@ -26,6 +26,9 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
|
||||
{option["type"] === "group" && <IconGroupCircle />}
|
||||
<div>
|
||||
<Text size="sm" lineClamp={1}>{option.label}</Text>
|
||||
{option["type"] === "user" && option["email"] && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>{option["email"]}</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
@ -47,6 +50,7 @@ export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
|
||||
const userItems = suggestion?.users.map((user: IUser) => ({
|
||||
value: `user-${user.id}`,
|
||||
label: user.name,
|
||||
email: user.email,
|
||||
avatarUrl: user.avatarUrl,
|
||||
type: "user",
|
||||
}));
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Text, Avatar, SimpleGrid, Card, rem } from "@mantine/core";
|
||||
import { Text, Avatar, SimpleGrid, Card, rem, Group, Button } from "@mantine/core";
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
prefetchSpace,
|
||||
@ -9,10 +9,11 @@ import { Link } from "react-router-dom";
|
||||
import classes from "./space-grid.module.css";
|
||||
import { formatMemberCount } from "@/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconArrowRight } from "@tabler/icons-react";
|
||||
|
||||
export default function SpaceGrid() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useGetSpacesQuery({ page: 1 });
|
||||
const { data, isLoading } = useGetSpacesQuery({ page: 1, limit: 9 });
|
||||
|
||||
const cards = data?.items.map((space, index) => (
|
||||
<Card
|
||||
@ -46,11 +47,25 @@ export default function SpaceGrid() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text fz="sm" fw={500} mb={"md"}>
|
||||
{t("Spaces you belong to")}
|
||||
</Text>
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
<Text fz="sm" fw={500}>
|
||||
{t("Spaces you belong to")}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }}>{cards}</SimpleGrid>
|
||||
|
||||
<Group justify="flex-end" mt="lg">
|
||||
<Button
|
||||
component={Link}
|
||||
to="/spaces"
|
||||
variant="subtle"
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
size="sm"
|
||||
>
|
||||
{t("View all spaces")}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
.spaceLink {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,160 @@
|
||||
import {
|
||||
Table,
|
||||
Text,
|
||||
Group,
|
||||
ActionIcon,
|
||||
Box,
|
||||
Space,
|
||||
Menu,
|
||||
Avatar,
|
||||
Anchor,
|
||||
} from "@mantine/core";
|
||||
import { IconDots, IconSettings } from "@tabler/icons-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { formatMemberCount } from "@/lib";
|
||||
import { getSpaceUrl } from "@/lib/config";
|
||||
import { prefetchSpace } from "@/features/space/queries/space-query";
|
||||
import { SearchInput } from "@/components/common/search-input";
|
||||
import Paginate from "@/components/common/paginate";
|
||||
import NoTableResults from "@/components/common/no-table-results";
|
||||
import SpaceSettingsModal from "@/features/space/components/settings-modal";
|
||||
import classes from "./all-spaces-list.module.css";
|
||||
|
||||
interface AllSpacesListProps {
|
||||
spaces: any[];
|
||||
onSearch: (query: string) => void;
|
||||
page: number;
|
||||
hasPrevPage?: boolean;
|
||||
hasNextPage?: boolean;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export default function AllSpacesList({
|
||||
spaces,
|
||||
onSearch,
|
||||
page,
|
||||
hasPrevPage,
|
||||
hasNextPage,
|
||||
onPageChange,
|
||||
}: AllSpacesListProps) {
|
||||
const { t } = useTranslation();
|
||||
const [settingsOpened, { open: openSettings, close: closeSettings }] =
|
||||
useDisclosure(false);
|
||||
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(null);
|
||||
|
||||
const handleOpenSettings = (spaceId: string) => {
|
||||
setSelectedSpaceId(spaceId);
|
||||
openSettings();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<SearchInput onSearch={onSearch} />
|
||||
|
||||
<Space h="md" />
|
||||
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("Space")}</Table.Th>
|
||||
<Table.Th>{t("Members")}</Table.Th>
|
||||
<Table.Th w={100}></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
|
||||
<Table.Tbody>
|
||||
{spaces.length > 0 ? (
|
||||
spaces.map((space) => (
|
||||
<Table.Tr key={space.id}>
|
||||
<Table.Td>
|
||||
<Anchor
|
||||
size="sm"
|
||||
underline="never"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
color: "var(--mantine-color-text)",
|
||||
}}
|
||||
component={Link}
|
||||
to={getSpaceUrl(space.slug)}
|
||||
>
|
||||
<Group
|
||||
gap="sm"
|
||||
wrap="nowrap"
|
||||
className={classes.spaceLink}
|
||||
onMouseEnter={() => prefetchSpace(space.slug, space.id)}
|
||||
>
|
||||
<Avatar
|
||||
color="initials"
|
||||
variant="filled"
|
||||
name={space.name}
|
||||
size={40}
|
||||
/>
|
||||
<div>
|
||||
<Text fz="sm" fw={500} lineClamp={1}>
|
||||
{space.name}
|
||||
</Text>
|
||||
{space.description && (
|
||||
<Text fz="xs" c="dimmed" lineClamp={2}>
|
||||
{space.description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
</Anchor>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{formatMemberCount(space.memberCount, t)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end">
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray">
|
||||
<IconDots size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconSettings size={16} />}
|
||||
onClick={() => handleOpenSettings(space.id)}
|
||||
>
|
||||
{t("Space settings")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
) : (
|
||||
<NoTableResults colSpan={3} />
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
|
||||
{spaces.length > 0 && (
|
||||
<Paginate
|
||||
currentPage={page}
|
||||
hasPrevPage={hasPrevPage}
|
||||
hasNextPage={hasNextPage}
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedSpaceId && (
|
||||
<SpaceSettingsModal
|
||||
spaceId={selectedSpaceId}
|
||||
opened={settingsOpened}
|
||||
onClose={closeSettings}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export { default as AllSpacesList } from "./all-spaces-list";
|
||||
@ -0,0 +1,15 @@
|
||||
import React from "react";
|
||||
import { isCloud } from "@/lib/config";
|
||||
import { useLicense } from "@/ee/hooks/use-license";
|
||||
import { MfaSettings } from "@/ee/mfa";
|
||||
|
||||
export function AccountMfaSection() {
|
||||
const { hasLicenseKey } = useLicense();
|
||||
const showMfa = isCloud() || hasLicenseKey;
|
||||
|
||||
if (!showMfa) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <MfaSettings />;
|
||||
}
|
||||
@ -22,7 +22,7 @@ export default function ChangeEmail() {
|
||||
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="md">{t("Email")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{currentUser?.user.email}
|
||||
@ -30,7 +30,7 @@ export default function ChangeEmail() {
|
||||
</div>
|
||||
|
||||
{/*
|
||||
<Button onClick={open} variant="default">
|
||||
<Button onClick={open} variant="default" style={{ whiteSpace: "nowrap" }}>
|
||||
{t("Change email")}
|
||||
</Button>
|
||||
*/}
|
||||
|
||||
@ -14,14 +14,14 @@ export default function ChangePassword() {
|
||||
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="md">{t("Password")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("You can change your password here.")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button onClick={open} variant="default">
|
||||
<Button onClick={open} variant="default" style={{ whiteSpace: "nowrap" }}>
|
||||
{t("Change password")}
|
||||
</Button>
|
||||
|
||||
|
||||
@ -66,8 +66,9 @@ export async function createInvitation(data: ICreateInvite) {
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function acceptInvitation(data: IAcceptInvite): Promise<void> {
|
||||
await api.post<void>("/workspace/invites/accept", data);
|
||||
export async function acceptInvitation(data: IAcceptInvite): Promise<{ requiresLogin?: boolean; }> {
|
||||
const req = await api.post("/workspace/invites/accept", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getInviteLink(data: {
|
||||
|
||||
@ -21,6 +21,7 @@ export interface IWorkspace {
|
||||
memberCount?: number;
|
||||
plan?: string;
|
||||
hasLicenseKey?: boolean;
|
||||
enforceMfa?: boolean;
|
||||
}
|
||||
|
||||
export interface ICreateInvite {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
const APP_ROUTE = {
|
||||
HOME: "/home",
|
||||
SPACES: "/spaces",
|
||||
AUTH: {
|
||||
LOGIN: "/login",
|
||||
SIGNUP: "/signup",
|
||||
@ -8,6 +9,8 @@ const APP_ROUTE = {
|
||||
PASSWORD_RESET: "/password-reset",
|
||||
CREATE_WORKSPACE: "/create",
|
||||
SELECT_WORKSPACE: "/select",
|
||||
MFA_CHALLENGE: "/login/mfa",
|
||||
MFA_SETUP_REQUIRED: "/login/mfa/setup",
|
||||
},
|
||||
SETTINGS: {
|
||||
ACCOUNT: {
|
||||
|
||||
@ -4,18 +4,21 @@ import ChangePassword from "@/features/user/components/change-password";
|
||||
import { Divider } from "@mantine/core";
|
||||
import AccountAvatar from "@/features/user/components/account-avatar";
|
||||
import SettingsTitle from "@/components/settings/settings-title.tsx";
|
||||
import {getAppName} from "@/lib/config.ts";
|
||||
import {Helmet} from "react-helmet-async";
|
||||
import { getAppName } from "@/lib/config.ts";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AccountMfaSection } from "@/features/user/components/account-mfa-section";
|
||||
|
||||
export default function AccountSettings() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t("My Profile")} - {getAppName()}</title>
|
||||
</Helmet>
|
||||
<Helmet>
|
||||
<title>
|
||||
{t("My Profile")} - {getAppName()}
|
||||
</title>
|
||||
</Helmet>
|
||||
<SettingsTitle title={t("My Profile")} />
|
||||
|
||||
<AccountAvatar />
|
||||
@ -29,6 +32,10 @@ export default function AccountSettings() {
|
||||
<Divider my="lg" />
|
||||
|
||||
<ChangePassword />
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
<AccountMfaSection />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
53
apps/client/src/pages/spaces/spaces.tsx
Normal file
53
apps/client/src/pages/spaces/spaces.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { Container, Title, Text, Group, Box } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { getAppName } from "@/lib/config";
|
||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
||||
import CreateSpaceModal from "@/features/space/components/create-space-modal";
|
||||
import { AllSpacesList } from "@/features/space/components/spaces-page";
|
||||
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
|
||||
import useUserRole from "@/hooks/use-user-role";
|
||||
|
||||
export default function Spaces() {
|
||||
const { t } = useTranslation();
|
||||
const { isAdmin } = useUserRole();
|
||||
const { search, page, setPage, handleSearch } = usePaginateAndSearch();
|
||||
|
||||
const { data, isLoading } = useGetSpacesQuery({
|
||||
page,
|
||||
limit: 30,
|
||||
query: search,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>
|
||||
{t("Spaces")} - {getAppName()}
|
||||
</title>
|
||||
</Helmet>
|
||||
|
||||
<Container size={"800"} pt="xl">
|
||||
<Group justify="space-between" mb="xl">
|
||||
<Title order={3}>{t("Spaces")}</Title>
|
||||
{isAdmin && <CreateSpaceModal />}
|
||||
</Group>
|
||||
|
||||
<Box>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
{t("Spaces you belong to")}
|
||||
</Text>
|
||||
|
||||
<AllSpacesList
|
||||
spaces={data?.items || []}
|
||||
onSearch={handleSearch}
|
||||
page={page}
|
||||
hasPrevPage={data?.meta?.hasPrevPage}
|
||||
hasNextPage={data?.meta?.hasNextPage}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</Box>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user