Merge branch 'main' into sso-group-sync

This commit is contained in:
Philipinho
2025-08-31 12:14:56 -07:00
193 changed files with 9415 additions and 1164 deletions

View File

@ -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}

View File

@ -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 */}

View File

@ -53,7 +53,7 @@ export interface IBillingPlan {
};
features: string[];
billingScheme: string | null;
pricingTiers: PricingTier[];
pricingTiers?: PricingTier[];
}
interface PricingTier {

View File

@ -0,0 +1,67 @@
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconCircleCheck, IconCircleCheckFilled } from "@tabler/icons-react";
import { useResolveCommentMutation } from "@/ee/comment/queries/comment-query";
import { useTranslation } from "react-i18next";
import { Editor } from "@tiptap/react";
interface ResolveCommentProps {
editor: Editor;
commentId: string;
pageId: string;
resolvedAt?: Date;
}
function ResolveComment({
editor,
commentId,
pageId,
resolvedAt,
}: ResolveCommentProps) {
const { t } = useTranslation();
const resolveCommentMutation = useResolveCommentMutation();
const isResolved = resolvedAt != null;
const iconColor = isResolved ? "green" : "gray";
const handleResolveToggle = async () => {
try {
await resolveCommentMutation.mutateAsync({
commentId,
pageId,
resolved: !isResolved,
});
if (editor) {
editor.commands.setCommentResolved(commentId, !isResolved);
}
//
} catch (error) {
console.error("Failed to toggle resolved state:", error);
}
};
return (
<Tooltip
label={isResolved ? t("Re-Open comment") : t("Resolve comment")}
position="top"
>
<ActionIcon
onClick={handleResolveToggle}
variant="subtle"
color={isResolved ? "green" : "gray"}
size="sm"
loading={resolveCommentMutation.isPending}
disabled={resolveCommentMutation.isPending}
>
{isResolved ? (
<IconCircleCheckFilled size={18} />
) : (
<IconCircleCheck size={18} />
)}
</ActionIcon>
</Tooltip>
);
}
export default ResolveComment;

View File

@ -0,0 +1,87 @@
import {
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import { resolveComment } from "@/features/comment/services/comment-service";
import {
IComment,
IResolveComment,
} from "@/features/comment/types/comment.types";
import { notifications } from "@mantine/notifications";
import { IPagination } from "@/lib/types.ts";
import { useTranslation } from "react-i18next";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
import { RQ_KEY } from "@/features/comment/queries/comment-query";
export function useResolveCommentMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
const emit = useQueryEmit();
return useMutation({
mutationFn: (data: IResolveComment) => resolveComment(data),
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: RQ_KEY(variables.pageId) });
const previousComments = queryClient.getQueryData(RQ_KEY(variables.pageId));
queryClient.setQueryData(RQ_KEY(variables.pageId), (old: IPagination<IComment>) => {
if (!old || !old.items) return old;
const updatedItems = old.items.map((comment) =>
comment.id === variables.commentId
? {
...comment,
resolvedAt: variables.resolved ? new Date() : null,
resolvedById: variables.resolved ? 'optimistic-user' : null,
resolvedBy: variables.resolved ? { id: 'optimistic-user', name: 'Resolving...', avatarUrl: null } : null
}
: comment,
);
return {
...old,
items: updatedItems,
};
});
return { previousComments };
},
onError: (err, variables, context) => {
if (context?.previousComments) {
queryClient.setQueryData(RQ_KEY(variables.pageId), context.previousComments);
}
notifications.show({
message: t("Failed to resolve comment"),
color: "red",
});
},
onSuccess: (data: IComment, variables) => {
const pageId = data.pageId;
const currentComments = queryClient.getQueryData(
RQ_KEY(pageId),
) as IPagination<IComment>;
if (currentComments && currentComments.items) {
const updatedComments = currentComments.items.map((comment) =>
comment.id === variables.commentId
? { ...comment, resolvedAt: data.resolvedAt, resolvedById: data.resolvedById, resolvedBy: data.resolvedBy }
: comment,
);
queryClient.setQueryData(RQ_KEY(pageId), {
...currentComments,
items: updatedComments,
});
}
emit({
operation: "resolveComment",
pageId: pageId,
commentId: variables.commentId,
resolved: variables.resolved,
resolvedAt: data.resolvedAt,
resolvedById: data.resolvedById,
resolvedBy: data.resolvedBy,
});
queryClient.invalidateQueries({ queryKey: RQ_KEY(pageId) });
notifications.show({
message: variables.resolved
? t("Comment resolved successfully")
: t("Comment re-opened successfully")
});
},
});
}

View File

@ -11,7 +11,7 @@ export default function OssDetails() {
withTableBorder
>
<Table.Caption>
To unlock enterprise features like SSO, contact sales@docmost.com.
To unlock enterprise features like SSO, MFA, Resolve comments, contact sales@docmost.com.
</Table.Caption>
<Table.Tbody>
<Table.Tr>

View 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>
);
}

View 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>
);
}

View 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);
}

View 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>
);
}

View 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>
);
}

View File

@ -0,0 +1,123 @@
import React, { useState } from "react";
import { Group, Text, Button, Tooltip } 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";
import { isCloud } from "@/lib/config.ts";
import useLicense from "@/ee/hooks/use-license.tsx";
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 { hasLicenseKey } = useLicense();
const { data: mfaStatus, isLoading } = useQuery({
queryKey: ["mfa-status"],
queryFn: getMfaStatus,
});
if (isLoading || !mfaStatus) {
return null;
}
const canUseMfa = isCloud() || hasLicenseKey;
// 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 ? (
<Tooltip
label={t("Available in enterprise edition")}
disabled={canUseMfa}
>
<Button
disabled={!canUseMfa}
variant="default"
onClick={() => setSetupModalOpen(true)}
style={{ whiteSpace: "nowrap" }}
>
{t("Add 2FA method")}
</Button>
</Tooltip>
) : (
<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)}
/>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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 };
}

View 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";

View 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 />;
}

View 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>
);
}

View 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 };
}
}

View 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;
}

View 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")}
/>
);
}

View File

@ -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>