diff --git a/apps/client/package.json b/apps/client/package.json index 9abc7c64..0c9fcc62 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -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", diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index b1178fad..48a4cdab 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -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" } diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 6fab378c..f2772bb8 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -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() { } /> } /> } /> + } /> + } + /> {!isCloud() && ( } /> @@ -58,7 +66,10 @@ export default function App() { )} }> - } /> + } + /> } /> @@ -67,6 +78,7 @@ export default function App() { }> } /> + } /> } /> ( @@ -38,7 +40,7 @@ export function AppHeader() { <> - {!isHomeRoute && ( + {!hideSidebar && ( <> - {!isHomeRoute && ( + {!hideSidebar && ( -
+
{user.name} @@ -101,6 +114,44 @@ export default function TopMenu() { {t("My preferences")} + + + }> + {t("Theme")} + + + + + setColorScheme("light")} + leftSection={} + rightSection={ + colorScheme === "light" ? : null + } + > + {t("Light")} + + setColorScheme("dark")} + leftSection={} + rightSection={ + colorScheme === "dark" ? : null + } + > + {t("Dark")} + + setColorScheme("auto")} + leftSection={} + rightSection={ + colorScheme === "auto" ? : null + } + > + {t("System settings")} + + + + }> diff --git a/apps/client/src/ee/billing/components/billing-details.tsx b/apps/client/src/ee/billing/components/billing-details.tsx index a4ea9547..0fb06147 100644 --- a/apps/client/src/ee/billing/components/billing-details.tsx +++ b/apps/client/src/ee/billing/components/billing-details.tsx @@ -117,7 +117,8 @@ export default function BillingDetails() { {billing.billingScheme === "tiered" && ( <> - ${billing.amount / 100} {billing.currency.toUpperCase()} + ${billing.amount / 100} {billing.currency.toUpperCase()} /{" "} + {billing.interval} per {billing.interval} @@ -129,7 +130,7 @@ export default function BillingDetails() { <> {(billing.amount / 100) * billing.quantity}{" "} - {billing.currency.toUpperCase()} + {billing.currency.toUpperCase()} / {billing.interval} ${billing.amount / 100} /user/{billing.interval} diff --git a/apps/client/src/ee/billing/components/billing-plans.tsx b/apps/client/src/ee/billing/components/billing-plans.tsx index 8d5f28d3..b57643b2 100644 --- a/apps/client/src/ee/billing/components/billing-plans.tsx +++ b/apps/client/src/ee/billing/components/billing-plans.tsx @@ -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( 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 ( + {/* Tiered pricing notice for eligible workspaces */} + {showTieredPricingNotice && !hasTieredPlans && ( + } + title="Want the old tiered pricing?" + color="blue" + mb="lg" + > + Contact support to switch back to our tiered pricing model. + + )} + {/* Controls Section */} {/* Team Size and Billing Controls */} - + )} @@ -102,17 +133,29 @@ export default function BillingPlans() { {/* Plans Grid */} {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 ( - ${isAnnual ? (price / 12).toFixed(0) : price} + ${displayPrice} - per {isAnnual ? "month" : "month"} + {plan.billingScheme === 'per_unit' + ? `per user/month` + : `per month`} - {isAnnual && ( - - Billed annually + + {isAnnual ? "Billed annually" : "Billed monthly"} + + {plan.billingScheme === 'tiered' && plan.pricingTiers && ( + + For {plan.pricingTiers.find(tier => tier.upTo.toString() === selectedTierValue)?.upTo || plan.pricingTiers[0].upTo} users )} - - For {planSelectedTier.upTo} users - {/* CTA Button */} {/* Features */} diff --git a/apps/client/src/ee/billing/types/billing.types.ts b/apps/client/src/ee/billing/types/billing.types.ts index dfa1a60b..58225519 100644 --- a/apps/client/src/ee/billing/types/billing.types.ts +++ b/apps/client/src/ee/billing/types/billing.types.ts @@ -53,7 +53,7 @@ export interface IBillingPlan { }; features: string[]; billingScheme: string | null; - pricingTiers: PricingTier[]; + pricingTiers?: PricingTier[]; } interface PricingTier { diff --git a/apps/client/src/ee/mfa/components/mfa-backup-code-input.tsx b/apps/client/src/ee/mfa/components/mfa-backup-code-input.tsx new file mode 100644 index 00000000..fef4b8cb --- /dev/null +++ b/apps/client/src/ee/mfa/components/mfa-backup-code-input.tsx @@ -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 ( + + } color="blue" variant="light"> + + {t( + "Enter one of your backup codes. Each backup code can only be used once.", + )} + + + + onChange(e.currentTarget.value.toUpperCase())} + error={error} + autoFocus + maxLength={8} + styles={{ + input: { + fontFamily: "monospace", + letterSpacing: "0.1em", + fontSize: "1rem", + }, + }} + /> + + + + + + + + ); +} \ No newline at end of file diff --git a/apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx b/apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx new file mode 100644 index 00000000..fdad6811 --- /dev/null +++ b/apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx @@ -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([]); + 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 ( + + + {!showNewCodes ? ( +
+ + } + title={t("About backup codes")} + color="blue" + variant="light" + > + + {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.", + )} + + + + + {t( + "You can regenerate new backup codes at any time. This will invalidate all existing codes.", + )} + + + + + + +
+ ) : ( + <> + } + title={t("Save your new backup codes")} + color="yellow" + > + + {t( + "Make sure to save these codes in a secure place. Your old backup codes are no longer valid.", + )} + + + + + + + {t("Your new backup codes")} + + + {({ copied, copy }) => ( + + )} + + + + {backupCodes.map((code, index) => ( + + {code} + + ))} + + + + + + )} +
+
+ ); +} diff --git a/apps/client/src/ee/mfa/components/mfa-challenge.module.css b/apps/client/src/ee/mfa/components/mfa-challenge.module.css new file mode 100644 index 00000000..45eb5df9 --- /dev/null +++ b/apps/client/src/ee/mfa/components/mfa-challenge.module.css @@ -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); +} \ No newline at end of file diff --git a/apps/client/src/ee/mfa/components/mfa-challenge.tsx b/apps/client/src/ee/mfa/components/mfa-challenge.tsx new file mode 100644 index 00000000..e067d730 --- /dev/null +++ b/apps/client/src/ee/mfa/components/mfa-challenge.tsx @@ -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; + +export function MfaChallenge() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [isLoading, setIsLoading] = useState(false); + const [useBackupCode, setUseBackupCode] = useState(false); + + const form = useForm({ + 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 ( + + + +
+ + + +
+ + + + {t("Two-factor authentication")} + + + {useBackupCode + ? t("Enter one of your backup codes") + : t("Enter the 6-digit code found in your authenticator app")} + + + + {!useBackupCode ? ( +
+ +
+ +
+ {form.errors.code && ( + + {form.errors.code} + + )} + + + + { + setUseBackupCode(true); + form.setFieldValue("code", ""); + form.clearErrors(); + }} + > + {t("Use backup code")} + +
+
+ ) : ( + form.setFieldValue("code", value)} + error={form.errors.code?.toString()} + onSubmit={() => handleSubmit(form.values)} + onCancel={() => { + setUseBackupCode(false); + form.setFieldValue("code", ""); + form.clearErrors(); + }} + isLoading={isLoading} + /> + )} +
+
+
+ ); +} diff --git a/apps/client/src/ee/mfa/components/mfa-disable-modal.tsx b/apps/client/src/ee/mfa/components/mfa-disable-modal.tsx new file mode 100644 index 00000000..4b58f074 --- /dev/null +++ b/apps/client/src/ee/mfa/components/mfa-disable-modal.tsx @@ -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 ( + +
+ + } + title={t("Warning")} + color="red" + variant="light" + > + + {t( + "Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.", + )} + + + + + {t( + "Please enter your password to disable two-factor authentication:", + )} + + + + + + + + + +
+
+ ); +} diff --git a/apps/client/src/ee/mfa/components/mfa-settings.tsx b/apps/client/src/ee/mfa/components/mfa-settings.tsx new file mode 100644 index 00000000..beab11a0 --- /dev/null +++ b/apps/client/src/ee/mfa/components/mfa-settings.tsx @@ -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 ( + <> + +
+ {t("2-step verification")} + + {!isMfaEnabled + ? t( + "Protect your account with an additional verification layer when signing in.", + ) + : t("Two-factor authentication is active on your account.")} + +
+ + {!isMfaEnabled ? ( + + ) : ( + + + + + )} +
+ + setSetupModalOpen(false)} + onComplete={handleSetupComplete} + /> + + setDisableModalOpen(false)} + onComplete={handleDisableComplete} + /> + + setBackupCodesModalOpen(false)} + /> + + ); +} diff --git a/apps/client/src/ee/mfa/components/mfa-setup-modal.tsx b/apps/client/src/ee/mfa/components/mfa-setup-modal.tsx new file mode 100644 index 00000000..124b622c --- /dev/null +++ b/apps/client/src/ee/mfa/components/mfa-setup-modal.tsx @@ -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(null); + const [backupCodes, setBackupCodes] = useState([]); + 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 ( + + + } + > +
+ + {setupMutation.isPending ? ( +
+ +
+ ) : setupData ? ( + <> + + {t("1. Scan this QR code with your authenticator app")} + + +
+ + MFA QR Code + +
+ + setManualEntryOpen(!manualEntryOpen)} + > + + {manualEntryOpen ? ( + + ) : ( + + )} + + {t("Can't scan the code?")} + + + + + + } + color="gray" + variant="light" + > + + {t( + "Enter this code manually in your authenticator app:", + )} + + + {setupData.manualKey} + + {({ copied, copy }) => ( + + + {copied ? ( + + ) : ( + + )} + + + )} + + + + + + + {t("2. Enter the 6-digit code from your authenticator")} + + + + + {form.errors.verificationCode && ( + + {form.errors.verificationCode} + + )} + + + + + ) : ( +
+ + {t("Failed to generate QR code. Please try again.")} + +
+ )} +
+
+
+ + } + > + + } + title={t("Save your backup codes")} + color="yellow" + > + + {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.", + )} + + + + + + + {t("Backup codes")} + + + + {({ copied, copy }) => ( + + )} + + + + + + {backupCodes.map((code, index) => ( + + {code} + + ))} + + + + + + +
+
+ ); +} diff --git a/apps/client/src/ee/mfa/components/mfa-setup-required.tsx b/apps/client/src/ee/mfa/components/mfa-setup-required.tsx new file mode 100644 index 00000000..c657abe9 --- /dev/null +++ b/apps/client/src/ee/mfa/components/mfa-setup-required.tsx @@ -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 ( + + + + + {t("Two-factor authentication required")} + + + } color="yellow"> + + {t( + "Your workspace requires two-factor authentication. Please set it up to continue.", + )} + + + + + {t( + "This adds an extra layer of security to your account by requiring a verification code from your authenticator app.", + )} + + + + + + + ); +} diff --git a/apps/client/src/ee/mfa/components/mfa.module.css b/apps/client/src/ee/mfa/components/mfa.module.css new file mode 100644 index 00000000..535704a5 --- /dev/null +++ b/apps/client/src/ee/mfa/components/mfa.module.css @@ -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; +} \ No newline at end of file diff --git a/apps/client/src/ee/mfa/hooks/use-mfa-page-protection.ts b/apps/client/src/ee/mfa/hooks/use-mfa-page-protection.ts new file mode 100644 index 00000000..9200cac7 --- /dev/null +++ b/apps/client/src/ee/mfa/hooks/use-mfa-page-protection.ts @@ -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 }; +} diff --git a/apps/client/src/ee/mfa/index.ts b/apps/client/src/ee/mfa/index.ts new file mode 100644 index 00000000..047b0a8d --- /dev/null +++ b/apps/client/src/ee/mfa/index.ts @@ -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"; diff --git a/apps/client/src/ee/mfa/pages/mfa-challenge-page.tsx b/apps/client/src/ee/mfa/pages/mfa-challenge-page.tsx new file mode 100644 index 00000000..40949fc7 --- /dev/null +++ b/apps/client/src/ee/mfa/pages/mfa-challenge-page.tsx @@ -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 ; +} diff --git a/apps/client/src/ee/mfa/pages/mfa-setup-required-page.tsx b/apps/client/src/ee/mfa/pages/mfa-setup-required-page.tsx new file mode 100644 index 00000000..0b5f756d --- /dev/null +++ b/apps/client/src/ee/mfa/pages/mfa-setup-required-page.tsx @@ -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 ( + + + +
+ + + +
+ + + + {t("Two-factor authentication required")} + + + {t( + "Your workspace requires two-factor authentication for all users", + )} + + + + } + color="blue" + variant="light" + w="100%" + > + + {t( + "To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.", + )} + + + + + + + + +
+
+ + setSetupModalOpen(false)} + onComplete={handleSetupComplete} + isRequired={true} + /> +
+ ); +} diff --git a/apps/client/src/ee/mfa/services/mfa-service.ts b/apps/client/src/ee/mfa/services/mfa-service.ts new file mode 100644 index 00000000..bf49d2eb --- /dev/null +++ b/apps/client/src/ee/mfa/services/mfa-service.ts @@ -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 { + const req = await api.post("/mfa/status"); + return req.data; +} + +export async function setupMfa( + data: MfaSetupRequest, +): Promise { + const req = await api.post("/mfa/setup", data); + return req.data; +} + +export async function enableMfa( + data: MfaEnableRequest, +): Promise { + const req = await api.post("/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 { + const req = await api.post( + "/mfa/generate-backup-codes", + data, + ); + return req.data; +} + +export async function verifyMfa(code: string): Promise { + const req = await api.post("/mfa/verify", { code }); + return req.data; +} + +export async function validateMfaAccess(): Promise { + try { + const res = await api.post("/mfa/validate-access"); + return res.data; + } catch { + return { valid: false }; + } +} diff --git a/apps/client/src/ee/mfa/types/mfa.types.ts b/apps/client/src/ee/mfa/types/mfa.types.ts new file mode 100644 index 00000000..ac032195 --- /dev/null +++ b/apps/client/src/ee/mfa/types/mfa.types.ts @@ -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; +} diff --git a/apps/client/src/ee/security/components/enforce-mfa.tsx b/apps/client/src/ee/security/components/enforce-mfa.tsx new file mode 100644 index 00000000..37cf5152 --- /dev/null +++ b/apps/client/src/ee/security/components/enforce-mfa.tsx @@ -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 ( + <> + + MFA + + +
+ {t("Enforce two-factor authentication")} + + {t( + "Once enforced, all members must enable two-factor authentication to access the workspace.", + )} + +
+ + +
+ + ); +} + +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) => { + 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 ( + + ); +} diff --git a/apps/client/src/ee/security/pages/security.tsx b/apps/client/src/ee/security/pages/security.tsx index de8efc06..82d8640f 100644 --- a/apps/client/src/ee/security/pages/security.tsx +++ b/apps/client/src/ee/security/pages/security.tsx @@ -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() { + + + + Single sign-on (SSO) diff --git a/apps/client/src/features/auth/components/invite-sign-up-form.tsx b/apps/client/src/features/auth/components/invite-sign-up-form.tsx index 2d7b3657..37397ef8 100644 --- a/apps/client/src/features/auth/components/invite-sign-up-form.tsx +++ b/apps/client/src/features/auth/components/invite-sign-up-form.tsx @@ -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"; diff --git a/apps/client/src/features/auth/hooks/use-auth.ts b/apps/client/src/features/auth/hooks/use-auth.ts index 2867f238..decb393f 100644 --- a/apps/client/src/features/auth/hooks/use-auth.ts +++ b/apps/client/src/features/auth/hooks/use-auth.ts @@ -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({ diff --git a/apps/client/src/features/auth/services/auth-service.ts b/apps/client/src/features/auth/services/auth-service.ts index 2008ecfc..20e437f3 100644 --- a/apps/client/src/features/auth/services/auth-service.ts +++ b/apps/client/src/features/auth/services/auth-service.ts @@ -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 { - await api.post("/auth/login", data); +export async function login(data: ILogin): Promise { + const response = await api.post("/auth/login", data); + return response.data; } export async function logout(): Promise { @@ -36,8 +38,9 @@ export async function forgotPassword(data: IForgotPassword): Promise { await api.post("/auth/forgot-password", data); } -export async function passwordReset(data: IPasswordReset): Promise { - await api.post("/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 { @@ -47,4 +50,4 @@ export async function verifyUserToken(data: IVerifyUserToken): Promise { export async function getCollabToken(): Promise { const req = await api.post("/auth/collab-token"); return req.data; -} +} \ No newline at end of file diff --git a/apps/client/src/features/auth/types/auth.types.ts b/apps/client/src/features/auth/types/auth.types.ts index 6a925a0a..71abc6b7 100644 --- a/apps/client/src/features/auth/types/auth.types.ts +++ b/apps/client/src/features/auth/types/auth.types.ts @@ -38,3 +38,10 @@ export interface IVerifyUserToken { export interface ICollabToken { token?: string; } + +export interface ILoginResponse { + userHasMfa?: boolean; + requiresMfaSetup?: boolean; + mfaToken?: string; + isMfaEnforced?: boolean; +} diff --git a/apps/client/src/features/editor/components/code-block/mermaid-view.tsx b/apps/client/src/features/editor/components/code-block/mermaid-view.tsx index 5f2cf845..d51e5604 100644 --- a/apps/client/src/features/editor/components/code-block/mermaid-view.tsx +++ b/apps/client/src/features/editor/components/code-block/mermaid-view.tsx @@ -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 { diff --git a/apps/client/src/features/editor/components/common/resizable-wrapper.module.css b/apps/client/src/features/editor/components/common/resizable-wrapper.module.css new file mode 100644 index 00000000..02791e86 --- /dev/null +++ b/apps/client/src/features/editor/components/common/resizable-wrapper.module.css @@ -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); + } +} diff --git a/apps/client/src/features/editor/components/common/resizable-wrapper.tsx b/apps/client/src/features/editor/components/common/resizable-wrapper.tsx new file mode 100644 index 00000000..c3cd1b62 --- /dev/null +++ b/apps/client/src/features/editor/components/common/resizable-wrapper.tsx @@ -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 = ({ + 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(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 ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {children} + {!!resizeParams &&
} + {shouldShowHandles && direction === "vertical" && ( +
+
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/apps/client/src/features/editor/components/embed/embed-view.module.css b/apps/client/src/features/editor/components/embed/embed-view.module.css new file mode 100644 index 00000000..c58f3965 --- /dev/null +++ b/apps/client/src/features/editor/components/embed/embed-view.module.css @@ -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; +} \ No newline at end of file diff --git a/apps/client/src/features/editor/components/embed/embed-view.tsx b/apps/client/src/features/editor/components/embed/embed-view.tsx index dfc6a5da..414ccdaf 100644 --- a/apps/client/src/features/editor/components/embed/embed-view.tsx +++ b/apps/client/src/features/editor/components/embed/embed-view.tsx @@ -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 ( {embedUrl ? ( - <> - - - - + +