mirror of
https://github.com/docmost/docmost.git
synced 2025-11-23 15:41:08 +10:00
Merge branch 'main' into sso-group-sync
This commit is contained in:
@ -117,7 +117,8 @@ export default function BillingDetails() {
|
||||
{billing.billingScheme === "tiered" && (
|
||||
<>
|
||||
<Text fw={700} fz="lg">
|
||||
${billing.amount / 100} {billing.currency.toUpperCase()}
|
||||
${billing.amount / 100} {billing.currency.toUpperCase()} /{" "}
|
||||
{billing.interval}
|
||||
</Text>
|
||||
<Text c="dimmed" fz="sm">
|
||||
per {billing.interval}
|
||||
@ -129,7 +130,7 @@ export default function BillingDetails() {
|
||||
<>
|
||||
<Text fw={700} fz="lg">
|
||||
{(billing.amount / 100) * billing.quantity}{" "}
|
||||
{billing.currency.toUpperCase()}
|
||||
{billing.currency.toUpperCase()} / {billing.interval}
|
||||
</Text>
|
||||
<Text c="dimmed" fz="sm">
|
||||
${billing.amount / 100} /user/{billing.interval}
|
||||
|
||||
@ -12,14 +12,18 @@ import {
|
||||
Badge,
|
||||
Flex,
|
||||
Switch,
|
||||
Alert,
|
||||
} from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
import { IconCheck, IconInfoCircle } from "@tabler/icons-react";
|
||||
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
|
||||
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
||||
|
||||
export default function BillingPlans() {
|
||||
const { data: plans } = useBillingPlans();
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const [isAnnual, setIsAnnual] = useState(true);
|
||||
const [selectedTierValue, setSelectedTierValue] = useState<string | null>(
|
||||
null,
|
||||
@ -36,49 +40,76 @@ export default function BillingPlans() {
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: remove by July 30.
|
||||
// Check if workspace was created between June 28 and July 14, 2025
|
||||
const showTieredPricingNotice = (() => {
|
||||
if (!workspace?.createdAt) return false;
|
||||
const createdDate = new Date(workspace.createdAt);
|
||||
const startDate = new Date('2025-06-20');
|
||||
const endDate = new Date('2025-07-14');
|
||||
return createdDate >= startDate && createdDate <= endDate;
|
||||
})();
|
||||
|
||||
if (!plans || plans.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstPlan = plans[0];
|
||||
// Check if any plan is tiered
|
||||
const hasTieredPlans = plans.some(plan => plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0);
|
||||
const firstTieredPlan = plans.find(plan => plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0);
|
||||
|
||||
// Set initial tier value if not set
|
||||
if (!selectedTierValue && firstPlan.pricingTiers.length > 0) {
|
||||
setSelectedTierValue(firstPlan.pricingTiers[0].upTo.toString());
|
||||
// Set initial tier value if not set and we have tiered plans
|
||||
if (hasTieredPlans && !selectedTierValue && firstTieredPlan) {
|
||||
setSelectedTierValue(firstTieredPlan.pricingTiers[0].upTo.toString());
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!selectedTierValue) {
|
||||
// For tiered plans, ensure we have a selected tier
|
||||
if (hasTieredPlans && !selectedTierValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectData = firstPlan.pricingTiers
|
||||
.filter((tier) => !tier.custom)
|
||||
const selectData = firstTieredPlan?.pricingTiers
|
||||
?.filter((tier) => !tier.custom)
|
||||
.map((tier, index) => {
|
||||
const prevMaxUsers =
|
||||
index > 0 ? firstPlan.pricingTiers[index - 1].upTo : 0;
|
||||
index > 0 ? firstTieredPlan.pricingTiers[index - 1].upTo : 0;
|
||||
return {
|
||||
value: tier.upTo.toString(),
|
||||
label: `${prevMaxUsers + 1}-${tier.upTo} users`,
|
||||
};
|
||||
});
|
||||
}) || [];
|
||||
|
||||
return (
|
||||
<Container size="xl" py="xl">
|
||||
{/* Tiered pricing notice for eligible workspaces */}
|
||||
{showTieredPricingNotice && !hasTieredPlans && (
|
||||
<Alert
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
title="Want the old tiered pricing?"
|
||||
color="blue"
|
||||
mb="lg"
|
||||
>
|
||||
Contact support to switch back to our tiered pricing model.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Controls Section */}
|
||||
<Stack gap="xl" mb="md">
|
||||
{/* Team Size and Billing Controls */}
|
||||
<Group justify="center" align="center" gap="sm">
|
||||
<Select
|
||||
label="Team size"
|
||||
description="Select the number of users"
|
||||
value={selectedTierValue}
|
||||
onChange={setSelectedTierValue}
|
||||
data={selectData}
|
||||
w={250}
|
||||
size="md"
|
||||
allowDeselect={false}
|
||||
/>
|
||||
{hasTieredPlans && (
|
||||
<Select
|
||||
label="Team size"
|
||||
description="Select the number of users"
|
||||
value={selectedTierValue}
|
||||
onChange={setSelectedTierValue}
|
||||
data={selectData}
|
||||
w={250}
|
||||
size="md"
|
||||
allowDeselect={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Group justify="center" align="start">
|
||||
<Flex justify="center" gap="md" align="center">
|
||||
@ -102,17 +133,29 @@ export default function BillingPlans() {
|
||||
{/* Plans Grid */}
|
||||
<Group justify="center" gap="lg" align="stretch">
|
||||
{plans.map((plan, index) => {
|
||||
const tieredPlan = plan;
|
||||
const planSelectedTier =
|
||||
tieredPlan.pricingTiers.find(
|
||||
(tier) => tier.upTo.toString() === selectedTierValue,
|
||||
) || tieredPlan.pricingTiers[0];
|
||||
|
||||
const price = isAnnual
|
||||
? planSelectedTier.yearly
|
||||
: planSelectedTier.monthly;
|
||||
let price;
|
||||
let displayPrice;
|
||||
const priceId = isAnnual ? plan.yearlyId : plan.monthlyId;
|
||||
|
||||
if (plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0) {
|
||||
// Tiered billing logic
|
||||
const planSelectedTier =
|
||||
plan.pricingTiers.find(
|
||||
(tier) => tier.upTo.toString() === selectedTierValue,
|
||||
) || plan.pricingTiers[0];
|
||||
|
||||
price = isAnnual
|
||||
? planSelectedTier.yearly
|
||||
: planSelectedTier.monthly;
|
||||
displayPrice = isAnnual ? (price / 12).toFixed(0) : price;
|
||||
} else {
|
||||
// Per-unit billing logic
|
||||
const monthlyPrice = parseFloat(plan.price?.monthly || '0');
|
||||
const yearlyPrice = parseFloat(plan.price?.yearly || '0');
|
||||
price = isAnnual ? yearlyPrice : monthlyPrice;
|
||||
displayPrice = isAnnual ? (yearlyPrice / 12).toFixed(0) : monthlyPrice;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={plan.name}
|
||||
@ -143,25 +186,27 @@ export default function BillingPlans() {
|
||||
<Stack gap="xs">
|
||||
<Group align="baseline" gap="xs">
|
||||
<Title order={1} size="h1">
|
||||
${isAnnual ? (price / 12).toFixed(0) : price}
|
||||
${displayPrice}
|
||||
</Title>
|
||||
<Text size="lg" c="dimmed">
|
||||
per {isAnnual ? "month" : "month"}
|
||||
{plan.billingScheme === 'per_unit'
|
||||
? `per user/month`
|
||||
: `per month`}
|
||||
</Text>
|
||||
</Group>
|
||||
{isAnnual && (
|
||||
<Text size="sm" c="dimmed">
|
||||
Billed annually
|
||||
<Text size="sm" c="dimmed">
|
||||
{isAnnual ? "Billed annually" : "Billed monthly"}
|
||||
</Text>
|
||||
{plan.billingScheme === 'tiered' && plan.pricingTiers && (
|
||||
<Text size="md" fw={500}>
|
||||
For {plan.pricingTiers.find(tier => tier.upTo.toString() === selectedTierValue)?.upTo || plan.pricingTiers[0].upTo} users
|
||||
</Text>
|
||||
)}
|
||||
<Text size="md" fw={500}>
|
||||
For {planSelectedTier.upTo} users
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{/* CTA Button */}
|
||||
<Button onClick={() => handleCheckout(priceId)} fullWidth>
|
||||
Upgrade
|
||||
Subscribe
|
||||
</Button>
|
||||
|
||||
{/* Features */}
|
||||
|
||||
@ -53,7 +53,7 @@ export interface IBillingPlan {
|
||||
};
|
||||
features: string[];
|
||||
billingScheme: string | null;
|
||||
pricingTiers: PricingTier[];
|
||||
pricingTiers?: PricingTier[];
|
||||
}
|
||||
|
||||
interface PricingTier {
|
||||
|
||||
67
apps/client/src/ee/comment/components/resolve-comment.tsx
Normal file
67
apps/client/src/ee/comment/components/resolve-comment.tsx
Normal 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;
|
||||
87
apps/client/src/ee/comment/queries/comment-query.ts
Normal file
87
apps/client/src/ee/comment/queries/comment-query.ts
Normal 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")
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -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>
|
||||
|
||||
81
apps/client/src/ee/mfa/components/mfa-backup-code-input.tsx
Normal file
81
apps/client/src/ee/mfa/components/mfa-backup-code-input.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import React from "react";
|
||||
import {
|
||||
TextInput,
|
||||
Button,
|
||||
Stack,
|
||||
Text,
|
||||
Alert,
|
||||
} from "@mantine/core";
|
||||
import { IconKey, IconAlertCircle } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface MfaBackupCodeInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: string;
|
||||
onSubmit: () => void;
|
||||
onCancel: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function MfaBackupCodeInput({
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isLoading,
|
||||
}: MfaBackupCodeInputProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="blue" variant="light">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"Enter one of your backup codes. Each backup code can only be used once.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<TextInput
|
||||
label={t("Backup code")}
|
||||
placeholder="XXXXXXXX"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.currentTarget.value.toUpperCase())}
|
||||
error={error}
|
||||
autoFocus
|
||||
maxLength={8}
|
||||
styles={{
|
||||
input: {
|
||||
fontFamily: "monospace",
|
||||
letterSpacing: "0.1em",
|
||||
fontSize: "1rem",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack>
|
||||
<Button
|
||||
fullWidth
|
||||
size="md"
|
||||
loading={isLoading}
|
||||
onClick={onSubmit}
|
||||
leftSection={<IconKey size={18} />}
|
||||
>
|
||||
{t("Verify backup code")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={onCancel}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t("Use authenticator app instead")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
193
apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx
Normal file
193
apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx
Normal file
@ -0,0 +1,193 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Button,
|
||||
Paper,
|
||||
Group,
|
||||
List,
|
||||
Code,
|
||||
CopyButton,
|
||||
Alert,
|
||||
PasswordInput,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconRefresh,
|
||||
IconCopy,
|
||||
IconCheck,
|
||||
IconAlertCircle,
|
||||
} from "@tabler/icons-react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { regenerateBackupCodes } from "@/ee/mfa";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zodResolver } from "mantine-form-zod-resolver";
|
||||
import { z } from "zod";
|
||||
|
||||
interface MfaBackupCodesModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const formSchema = z.object({
|
||||
confirmPassword: z.string().min(1, { message: "Password is required" }),
|
||||
});
|
||||
|
||||
export function MfaBackupCodesModal({
|
||||
opened,
|
||||
onClose,
|
||||
}: MfaBackupCodesModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const [showNewCodes, setShowNewCodes] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
validate: zodResolver(formSchema),
|
||||
initialValues: {
|
||||
confirmPassword: "",
|
||||
},
|
||||
});
|
||||
|
||||
const regenerateMutation = useMutation({
|
||||
mutationFn: (data: { confirmPassword: string }) =>
|
||||
regenerateBackupCodes(data),
|
||||
onSuccess: (data) => {
|
||||
setBackupCodes(data.backupCodes);
|
||||
setShowNewCodes(true);
|
||||
form.reset();
|
||||
notifications.show({
|
||||
title: t("Success"),
|
||||
message: t("New backup codes have been generated"),
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
notifications.show({
|
||||
title: t("Error"),
|
||||
message:
|
||||
error.response?.data?.message ||
|
||||
t("Failed to regenerate backup codes"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleRegenerate = (values: { confirmPassword: string }) => {
|
||||
regenerateMutation.mutate(values);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setShowNewCodes(false);
|
||||
setBackupCodes([]);
|
||||
form.reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={t("Backup codes")}
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
{!showNewCodes ? (
|
||||
<form onSubmit={form.onSubmit(handleRegenerate)}>
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={20} />}
|
||||
title={t("About backup codes")}
|
||||
color="blue"
|
||||
variant="light"
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"You can regenerate new backup codes at any time. This will invalidate all existing codes.",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<PasswordInput
|
||||
label={t("Confirm password")}
|
||||
placeholder={t("Enter your password")}
|
||||
variant="filled"
|
||||
{...form.getInputProps("confirmPassword")}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
loading={regenerateMutation.isPending}
|
||||
leftSection={<IconRefresh size={18} />}
|
||||
>
|
||||
{t("Generate new backup codes")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={20} />}
|
||||
title={t("Save your new backup codes")}
|
||||
color="yellow"
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Paper p="md" withBorder>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text size="sm" fw={600}>
|
||||
{t("Your new backup codes")}
|
||||
</Text>
|
||||
<CopyButton value={backupCodes.join("\n")}>
|
||||
{({ copied, copy }) => (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onClick={copy}
|
||||
leftSection={
|
||||
copied ? (
|
||||
<IconCheck size={14} />
|
||||
) : (
|
||||
<IconCopy size={14} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{copied ? t("Copied") : t("Copy")}
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Group>
|
||||
<List size="sm" spacing="xs">
|
||||
{backupCodes.map((code, index) => (
|
||||
<List.Item key={index}>
|
||||
<Code>{code}</Code>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
onClick={handleClose}
|
||||
leftSection={<IconCheck size={18} />}
|
||||
>
|
||||
{t("I've saved my backup codes")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
12
apps/client/src/ee/mfa/components/mfa-challenge.module.css
Normal file
12
apps/client/src/ee/mfa/components/mfa-challenge.module.css
Normal file
@ -0,0 +1,12 @@
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.paper {
|
||||
width: 100%;
|
||||
box-shadow: var(--mantine-shadow-lg);
|
||||
}
|
||||
160
apps/client/src/ee/mfa/components/mfa-challenge.tsx
Normal file
160
apps/client/src/ee/mfa/components/mfa-challenge.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Container,
|
||||
Title,
|
||||
Text,
|
||||
PinInput,
|
||||
Button,
|
||||
Stack,
|
||||
Anchor,
|
||||
Paper,
|
||||
Center,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zodResolver } from "mantine-form-zod-resolver";
|
||||
import { IconDeviceMobile, IconLock } from "@tabler/icons-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import classes from "./mfa-challenge.module.css";
|
||||
import { verifyMfa } from "@/ee/mfa";
|
||||
import APP_ROUTE from "@/lib/app-route";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as z from "zod";
|
||||
import { MfaBackupCodeInput } from "./mfa-backup-code-input";
|
||||
|
||||
const formSchema = z.object({
|
||||
code: z
|
||||
.string()
|
||||
.refine(
|
||||
(val) => (val.length === 6 && /^\d{6}$/.test(val)) || val.length === 8,
|
||||
{
|
||||
message: "Enter a 6-digit code or 8-character backup code",
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
type MfaChallengeFormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function MfaChallenge() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [useBackupCode, setUseBackupCode] = useState(false);
|
||||
|
||||
const form = useForm<MfaChallengeFormValues>({
|
||||
validate: zodResolver(formSchema),
|
||||
initialValues: {
|
||||
code: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: MfaChallengeFormValues) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await verifyMfa(values.code);
|
||||
navigate(APP_ROUTE.HOME);
|
||||
} catch (error: any) {
|
||||
setIsLoading(false);
|
||||
notifications.show({
|
||||
message:
|
||||
error.response?.data?.message || t("Invalid verification code"),
|
||||
color: "red",
|
||||
});
|
||||
form.setFieldValue("code", "");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size={420} className={classes.container}>
|
||||
<Paper radius="lg" p={40} className={classes.paper}>
|
||||
<Stack align="center" gap="xl">
|
||||
<Center>
|
||||
<ThemeIcon size={80} radius="xl" variant="light" color="blue">
|
||||
<IconDeviceMobile size={40} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
</Center>
|
||||
|
||||
<Stack align="center" gap="xs">
|
||||
<Title order={2} ta="center" fw={600}>
|
||||
{t("Two-factor authentication")}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{useBackupCode
|
||||
? t("Enter one of your backup codes")
|
||||
: t("Enter the 6-digit code found in your authenticator app")}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{!useBackupCode ? (
|
||||
<form
|
||||
onSubmit={form.onSubmit(handleSubmit)}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<Center>
|
||||
<PinInput
|
||||
length={6}
|
||||
type="number"
|
||||
autoFocus
|
||||
oneTimeCode
|
||||
{...form.getInputProps("code")}
|
||||
error={!!form.errors.code}
|
||||
styles={{
|
||||
input: {
|
||||
fontSize: "1.2rem",
|
||||
textAlign: "center",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Center>
|
||||
{form.errors.code && (
|
||||
<Text c="red" size="sm" ta="center">
|
||||
{form.errors.code}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
size="md"
|
||||
loading={isLoading}
|
||||
leftSection={<IconLock size={18} />}
|
||||
>
|
||||
{t("Verify")}
|
||||
</Button>
|
||||
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
onClick={() => {
|
||||
setUseBackupCode(true);
|
||||
form.setFieldValue("code", "");
|
||||
form.clearErrors();
|
||||
}}
|
||||
>
|
||||
{t("Use backup code")}
|
||||
</Anchor>
|
||||
</Stack>
|
||||
</form>
|
||||
) : (
|
||||
<MfaBackupCodeInput
|
||||
value={form.values.code}
|
||||
onChange={(value) => form.setFieldValue("code", value)}
|
||||
error={form.errors.code?.toString()}
|
||||
onSubmit={() => handleSubmit(form.values)}
|
||||
onCancel={() => {
|
||||
setUseBackupCode(false);
|
||||
form.setFieldValue("code", "");
|
||||
form.clearErrors();
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
124
apps/client/src/ee/mfa/components/mfa-disable-modal.tsx
Normal file
124
apps/client/src/ee/mfa/components/mfa-disable-modal.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Button,
|
||||
PasswordInput,
|
||||
Alert,
|
||||
} from "@mantine/core";
|
||||
import { IconShieldOff, IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zodResolver } from "mantine-form-zod-resolver";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { disableMfa } from "@/ee/mfa";
|
||||
|
||||
interface MfaDisableModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
const formSchema = z.object({
|
||||
confirmPassword: z.string().min(1, { message: "Password is required" }),
|
||||
});
|
||||
|
||||
export function MfaDisableModal({
|
||||
opened,
|
||||
onClose,
|
||||
onComplete,
|
||||
}: MfaDisableModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const form = useForm({
|
||||
validate: zodResolver(formSchema),
|
||||
initialValues: {
|
||||
confirmPassword: "",
|
||||
},
|
||||
});
|
||||
|
||||
const disableMutation = useMutation({
|
||||
mutationFn: disableMfa,
|
||||
onSuccess: () => {
|
||||
onComplete();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
notifications.show({
|
||||
title: t("Error"),
|
||||
message: error.response?.data?.message || t("Failed to disable MFA"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: { confirmPassword: string }) => {
|
||||
await disableMutation.mutateAsync(values);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
form.reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={t("Disable two-factor authentication")}
|
||||
size="md"
|
||||
>
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
icon={<IconAlertTriangle size={20} />}
|
||||
title={t("Warning")}
|
||||
color="red"
|
||||
variant="light"
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"Please enter your password to disable two-factor authentication:",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<PasswordInput
|
||||
label={t("Password")}
|
||||
placeholder={t("Enter your password")}
|
||||
{...form.getInputProps("confirmPassword")}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<Stack gap="sm">
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
color="red"
|
||||
loading={disableMutation.isPending}
|
||||
leftSection={<IconShieldOff size={18} />}
|
||||
>
|
||||
{t("Disable two-factor authentication")}
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="default"
|
||||
onClick={handleClose}
|
||||
disabled={disableMutation.isPending}
|
||||
>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
123
apps/client/src/ee/mfa/components/mfa-settings.tsx
Normal file
123
apps/client/src/ee/mfa/components/mfa-settings.tsx
Normal 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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
347
apps/client/src/ee/mfa/components/mfa-setup-modal.tsx
Normal file
347
apps/client/src/ee/mfa/components/mfa-setup-modal.tsx
Normal file
@ -0,0 +1,347 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Button,
|
||||
Group,
|
||||
Stepper,
|
||||
Center,
|
||||
Image,
|
||||
PinInput,
|
||||
Alert,
|
||||
List,
|
||||
CopyButton,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Paper,
|
||||
Code,
|
||||
Loader,
|
||||
Collapse,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconQrcode,
|
||||
IconShieldCheck,
|
||||
IconKey,
|
||||
IconCopy,
|
||||
IconCheck,
|
||||
IconAlertCircle,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconPrinter,
|
||||
} from "@tabler/icons-react";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { setupMfa, enableMfa } from "@/ee/mfa";
|
||||
import { zodResolver } from "mantine-form-zod-resolver";
|
||||
import { z } from "zod";
|
||||
|
||||
interface MfaSetupModalProps {
|
||||
opened: boolean;
|
||||
onClose?: () => void;
|
||||
onComplete: () => void;
|
||||
isRequired?: boolean;
|
||||
}
|
||||
|
||||
interface SetupData {
|
||||
secret: string;
|
||||
qrCode: string;
|
||||
manualKey: string;
|
||||
}
|
||||
|
||||
const formSchema = z.object({
|
||||
verificationCode: z
|
||||
.string()
|
||||
.length(6, { message: "Please enter a 6-digit code" }),
|
||||
});
|
||||
|
||||
export function MfaSetupModal({
|
||||
opened,
|
||||
onClose,
|
||||
onComplete,
|
||||
isRequired = false,
|
||||
}: MfaSetupModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [active, setActive] = useState(0);
|
||||
const [setupData, setSetupData] = useState<SetupData | null>(null);
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const [manualEntryOpen, setManualEntryOpen] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
validate: zodResolver(formSchema),
|
||||
initialValues: {
|
||||
verificationCode: "",
|
||||
},
|
||||
});
|
||||
|
||||
const setupMutation = useMutation({
|
||||
mutationFn: () => setupMfa({ method: "totp" }),
|
||||
onSuccess: (data) => {
|
||||
setSetupData(data);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
notifications.show({
|
||||
title: t("Error"),
|
||||
message: error.response?.data?.message || t("Failed to setup MFA"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Generate QR code when modal opens
|
||||
React.useEffect(() => {
|
||||
if (opened && !setupData && !setupMutation.isPending) {
|
||||
setupMutation.mutate();
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
const enableMutation = useMutation({
|
||||
mutationFn: (verificationCode: string) =>
|
||||
enableMfa({
|
||||
secret: setupData!.secret,
|
||||
verificationCode,
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
setBackupCodes(data.backupCodes);
|
||||
setActive(1); // Move to backup codes step
|
||||
},
|
||||
onError: (error: any) => {
|
||||
notifications.show({
|
||||
title: t("Error"),
|
||||
message:
|
||||
error.response?.data?.message || t("Invalid verification code"),
|
||||
color: "red",
|
||||
});
|
||||
form.setFieldValue("verificationCode", "");
|
||||
},
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
if (active === 1 && backupCodes.length > 0) {
|
||||
onComplete();
|
||||
}
|
||||
onClose();
|
||||
// Reset state
|
||||
setTimeout(() => {
|
||||
setActive(0);
|
||||
setSetupData(null);
|
||||
setBackupCodes([]);
|
||||
setManualEntryOpen(false);
|
||||
form.reset();
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const handleVerify = async (values: { verificationCode: string }) => {
|
||||
await enableMutation.mutateAsync(values.verificationCode);
|
||||
};
|
||||
|
||||
const handlePrintBackupCodes = () => {
|
||||
window.print();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={t("Set up two-factor authentication")}
|
||||
size="md"
|
||||
>
|
||||
<Stepper active={active} size="sm">
|
||||
<Stepper.Step
|
||||
label={t("Setup & Verify")}
|
||||
description={t("Add to authenticator")}
|
||||
icon={<IconQrcode size={18} />}
|
||||
>
|
||||
<form onSubmit={form.onSubmit(handleVerify)}>
|
||||
<Stack gap="md" mt="xl">
|
||||
{setupMutation.isPending ? (
|
||||
<Center py="xl">
|
||||
<Loader size="lg" />
|
||||
</Center>
|
||||
) : setupData ? (
|
||||
<>
|
||||
<Text size="sm">
|
||||
{t("1. Scan this QR code with your authenticator app")}
|
||||
</Text>
|
||||
|
||||
<Center>
|
||||
<Paper p="md" withBorder>
|
||||
<Image
|
||||
src={setupData.qrCode}
|
||||
alt="MFA QR Code"
|
||||
width={200}
|
||||
height={200}
|
||||
/>
|
||||
</Paper>
|
||||
</Center>
|
||||
|
||||
<UnstyledButton
|
||||
onClick={() => setManualEntryOpen(!manualEntryOpen)}
|
||||
>
|
||||
<Group gap="xs">
|
||||
{manualEntryOpen ? (
|
||||
<IconChevronDown size={16} />
|
||||
) : (
|
||||
<IconChevronRight size={16} />
|
||||
)}
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Can't scan the code?")}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
<Collapse in={manualEntryOpen}>
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={20} />}
|
||||
color="gray"
|
||||
variant="light"
|
||||
>
|
||||
<Text size="sm" mb="sm">
|
||||
{t(
|
||||
"Enter this code manually in your authenticator app:",
|
||||
)}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Code block>{setupData.manualKey}</Code>
|
||||
<CopyButton value={setupData.manualKey}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip label={copied ? t("Copied") : t("Copy")}>
|
||||
<ActionIcon
|
||||
color={copied ? "green" : "gray"}
|
||||
onClick={copy}
|
||||
>
|
||||
{copied ? (
|
||||
<IconCheck size={16} />
|
||||
) : (
|
||||
<IconCopy size={16} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Group>
|
||||
</Alert>
|
||||
</Collapse>
|
||||
|
||||
<Text size="sm" mt="md">
|
||||
{t("2. Enter the 6-digit code from your authenticator")}
|
||||
</Text>
|
||||
|
||||
<Stack align="center">
|
||||
<PinInput
|
||||
length={6}
|
||||
type="number"
|
||||
autoFocus
|
||||
oneTimeCode
|
||||
{...form.getInputProps("verificationCode")}
|
||||
styles={{
|
||||
input: {
|
||||
fontSize: "1.2rem",
|
||||
textAlign: "center",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{form.errors.verificationCode && (
|
||||
<Text c="red" size="sm">
|
||||
{form.errors.verificationCode}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
loading={enableMutation.isPending}
|
||||
leftSection={<IconShieldCheck size={18} />}
|
||||
>
|
||||
{t("Verify and enable")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Center py="xl">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Failed to generate QR code. Please try again.")}
|
||||
</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Stack>
|
||||
</form>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label={t("Backup")}
|
||||
description={t("Save codes")}
|
||||
icon={<IconKey size={18} />}
|
||||
>
|
||||
<Stack gap="md" mt="xl">
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={20} />}
|
||||
title={t("Save your backup codes")}
|
||||
color="yellow"
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Paper p="md" withBorder>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text size="sm" fw={600}>
|
||||
{t("Backup codes")}
|
||||
</Text>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<CopyButton value={backupCodes.join("\n")}>
|
||||
{({ copied, copy }) => (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onClick={copy}
|
||||
leftSection={
|
||||
copied ? (
|
||||
<IconCheck size={14} />
|
||||
) : (
|
||||
<IconCopy size={14} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{copied ? t("Copied") : t("Copy")}
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onClick={handlePrintBackupCodes}
|
||||
leftSection={<IconPrinter size={14} />}
|
||||
>
|
||||
{t("Print")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
<List size="sm" spacing="xs">
|
||||
{backupCodes.map((code, index) => (
|
||||
<List.Item key={index}>
|
||||
<Code>{code}</Code>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
onClick={handleClose}
|
||||
leftSection={<IconCheck size={18} />}
|
||||
>
|
||||
{t("I've saved my backup codes")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
</Stepper>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
48
apps/client/src/ee/mfa/components/mfa-setup-required.tsx
Normal file
48
apps/client/src/ee/mfa/components/mfa-setup-required.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import React from "react";
|
||||
import { Container, Paper, Title, Text, Alert, Stack } from "@mantine/core";
|
||||
import { IconAlertCircle } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MfaSetupModal } from "@/ee/mfa";
|
||||
import APP_ROUTE from "@/lib/app-route.ts";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export default function MfaSetupRequired() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSetupComplete = () => {
|
||||
navigate(APP_ROUTE.HOME);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
<Paper shadow="sm" p="xl" radius="md" withBorder>
|
||||
<Stack>
|
||||
<Title order={2} ta="center">
|
||||
{t("Two-factor authentication required")}
|
||||
</Title>
|
||||
|
||||
<Alert icon={<IconAlertCircle size="1rem" />} color="yellow">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"Your workspace requires two-factor authentication. Please set it up to continue.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
{t(
|
||||
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<MfaSetupModal
|
||||
opened={true}
|
||||
onComplete={handleSetupComplete}
|
||||
isRequired={true}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
31
apps/client/src/ee/mfa/components/mfa.module.css
Normal file
31
apps/client/src/ee/mfa/components/mfa.module.css
Normal file
@ -0,0 +1,31 @@
|
||||
.qrCodeContainer {
|
||||
background-color: white;
|
||||
padding: 1rem;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.backupCodesList {
|
||||
font-family: var(--mantine-font-family-monospace);
|
||||
background-color: var(--mantine-color-gray-0);
|
||||
padding: 1rem;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
|
||||
@mixin dark {
|
||||
background-color: var(--mantine-color-dark-7);
|
||||
}
|
||||
}
|
||||
|
||||
.codeItem {
|
||||
padding: 0.25rem 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.setupStep {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.verificationInput {
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
51
apps/client/src/ee/mfa/hooks/use-mfa-page-protection.ts
Normal file
51
apps/client/src/ee/mfa/hooks/use-mfa-page-protection.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import APP_ROUTE from "@/lib/app-route";
|
||||
import { validateMfaAccess } from "@/ee/mfa";
|
||||
|
||||
export function useMfaPageProtection() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [isValidating, setIsValidating] = useState(true);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAccess = async () => {
|
||||
const result = await validateMfaAccess();
|
||||
|
||||
if (!result.valid) {
|
||||
navigate(APP_ROUTE.AUTH.LOGIN);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user is on the correct page based on their MFA state
|
||||
const isOnChallengePage =
|
||||
location.pathname === APP_ROUTE.AUTH.MFA_CHALLENGE;
|
||||
const isOnSetupPage =
|
||||
location.pathname === APP_ROUTE.AUTH.MFA_SETUP_REQUIRED;
|
||||
|
||||
if (result.requiresMfaSetup && !isOnSetupPage) {
|
||||
// User needs to set up MFA but is on challenge page
|
||||
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
|
||||
} else if (
|
||||
!result.requiresMfaSetup &&
|
||||
result.userHasMfa &&
|
||||
!isOnChallengePage
|
||||
) {
|
||||
// User has MFA and should be on challenge page
|
||||
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
|
||||
} else if (!result.isTransferToken) {
|
||||
// User has a regular auth token, shouldn't be on MFA pages
|
||||
navigate(APP_ROUTE.HOME);
|
||||
} else {
|
||||
setIsValid(true);
|
||||
}
|
||||
|
||||
setIsValidating(false);
|
||||
};
|
||||
|
||||
checkAccess();
|
||||
}, [navigate, location.pathname]);
|
||||
|
||||
return { isValidating, isValid };
|
||||
}
|
||||
19
apps/client/src/ee/mfa/index.ts
Normal file
19
apps/client/src/ee/mfa/index.ts
Normal file
@ -0,0 +1,19 @@
|
||||
// Components
|
||||
export { MfaChallenge } from "./components/mfa-challenge";
|
||||
export { MfaSettings } from "./components/mfa-settings";
|
||||
export { MfaSetupModal } from "./components/mfa-setup-modal";
|
||||
export { MfaDisableModal } from "./components/mfa-disable-modal";
|
||||
export { MfaBackupCodesModal } from "./components/mfa-backup-codes-modal";
|
||||
|
||||
// Pages
|
||||
export { MfaChallengePage } from "./pages/mfa-challenge-page";
|
||||
export { MfaSetupRequiredPage } from "./pages/mfa-setup-required-page";
|
||||
|
||||
// Services
|
||||
export * from "./services/mfa-service";
|
||||
|
||||
// Types
|
||||
export * from "./types/mfa.types";
|
||||
|
||||
// Hooks
|
||||
export { useMfaPageProtection } from "./hooks/use-mfa-page-protection.ts";
|
||||
13
apps/client/src/ee/mfa/pages/mfa-challenge-page.tsx
Normal file
13
apps/client/src/ee/mfa/pages/mfa-challenge-page.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import { MfaChallenge } from "@/ee/mfa";
|
||||
import { useMfaPageProtection } from "@/ee/mfa";
|
||||
|
||||
export function MfaChallengePage() {
|
||||
const { isValid } = useMfaPageProtection();
|
||||
|
||||
if (!isValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <MfaChallenge />;
|
||||
}
|
||||
113
apps/client/src/ee/mfa/pages/mfa-setup-required-page.tsx
Normal file
113
apps/client/src/ee/mfa/pages/mfa-setup-required-page.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
import React, { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Container,
|
||||
Title,
|
||||
Text,
|
||||
Button,
|
||||
Stack,
|
||||
Paper,
|
||||
Alert,
|
||||
Center,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { IconShieldCheck, IconAlertCircle } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import APP_ROUTE from "@/lib/app-route";
|
||||
import { MfaSetupModal } from "@/ee/mfa";
|
||||
import classes from "@/features/auth/components/auth.module.css";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useMfaPageProtection } from "@/ee/mfa";
|
||||
|
||||
export function MfaSetupRequiredPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [setupModalOpen, setSetupModalOpen] = useState(false);
|
||||
const { isValid } = useMfaPageProtection();
|
||||
|
||||
const handleSetupComplete = async () => {
|
||||
setSetupModalOpen(false);
|
||||
|
||||
notifications.show({
|
||||
title: t("Success"),
|
||||
message: t(
|
||||
"Two-factor authentication has been set up. Please log in again.",
|
||||
),
|
||||
});
|
||||
|
||||
navigate(APP_ROUTE.AUTH.LOGIN);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
navigate(APP_ROUTE.AUTH.LOGIN);
|
||||
};
|
||||
|
||||
if (!isValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size={480} className={classes.container}>
|
||||
<Paper radius="lg" p={40}>
|
||||
<Stack align="center" gap="xl">
|
||||
<Center>
|
||||
<ThemeIcon size={80} radius="xl" variant="light" color="blue">
|
||||
<IconShieldCheck size={40} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
</Center>
|
||||
|
||||
<Stack align="center" gap="xs">
|
||||
<Title order={2} ta="center" fw={600}>
|
||||
{t("Two-factor authentication required")}
|
||||
</Title>
|
||||
<Text size="md" c="dimmed" ta="center">
|
||||
{t(
|
||||
"Your workspace requires two-factor authentication for all users",
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={20} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
w="100%"
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Stack w="100%" gap="sm">
|
||||
<Button
|
||||
fullWidth
|
||||
size="md"
|
||||
onClick={() => setSetupModalOpen(true)}
|
||||
leftSection={<IconShieldCheck size={18} />}
|
||||
>
|
||||
{t("Set up two-factor authentication")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
{t("Cancel and logout")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<MfaSetupModal
|
||||
opened={setupModalOpen}
|
||||
onClose={() => setSetupModalOpen(false)}
|
||||
onComplete={handleSetupComplete}
|
||||
isRequired={true}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
61
apps/client/src/ee/mfa/services/mfa-service.ts
Normal file
61
apps/client/src/ee/mfa/services/mfa-service.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import api from "@/lib/api-client";
|
||||
import {
|
||||
MfaBackupCodesResponse,
|
||||
MfaDisableRequest,
|
||||
MfaEnableRequest,
|
||||
MfaEnableResponse,
|
||||
MfaSetupRequest,
|
||||
MfaSetupResponse,
|
||||
MfaStatusResponse,
|
||||
MfaAccessValidationResponse,
|
||||
} from "@/ee/mfa";
|
||||
|
||||
export async function getMfaStatus(): Promise<MfaStatusResponse> {
|
||||
const req = await api.post("/mfa/status");
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function setupMfa(
|
||||
data: MfaSetupRequest,
|
||||
): Promise<MfaSetupResponse> {
|
||||
const req = await api.post<MfaSetupResponse>("/mfa/setup", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function enableMfa(
|
||||
data: MfaEnableRequest,
|
||||
): Promise<MfaEnableResponse> {
|
||||
const req = await api.post<MfaEnableResponse>("/mfa/enable", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function disableMfa(
|
||||
data: MfaDisableRequest,
|
||||
): Promise<{ success: boolean }> {
|
||||
const req = await api.post<{ success: boolean }>("/mfa/disable", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function regenerateBackupCodes(data: {
|
||||
confirmPassword: string;
|
||||
}): Promise<MfaBackupCodesResponse> {
|
||||
const req = await api.post<MfaBackupCodesResponse>(
|
||||
"/mfa/generate-backup-codes",
|
||||
data,
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function verifyMfa(code: string): Promise<any> {
|
||||
const req = await api.post("/mfa/verify", { code });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function validateMfaAccess(): Promise<MfaAccessValidationResponse> {
|
||||
try {
|
||||
const res = await api.post("/mfa/validate-access");
|
||||
return res.data;
|
||||
} catch {
|
||||
return { valid: false };
|
||||
}
|
||||
}
|
||||
62
apps/client/src/ee/mfa/types/mfa.types.ts
Normal file
62
apps/client/src/ee/mfa/types/mfa.types.ts
Normal file
@ -0,0 +1,62 @@
|
||||
export interface MfaMethod {
|
||||
type: 'totp' | 'email';
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface MfaSettings {
|
||||
isEnabled: boolean;
|
||||
methods: MfaMethod[];
|
||||
backupCodesCount: number;
|
||||
lastUpdated?: string;
|
||||
}
|
||||
|
||||
export interface MfaSetupState {
|
||||
method: 'totp' | 'email';
|
||||
secret?: string;
|
||||
qrCode?: string;
|
||||
manualEntry?: string;
|
||||
backupCodes?: string[];
|
||||
}
|
||||
|
||||
export interface MfaStatusResponse {
|
||||
isEnabled?: boolean;
|
||||
method?: string | null;
|
||||
backupCodesCount?: number;
|
||||
}
|
||||
|
||||
export interface MfaSetupRequest {
|
||||
method: 'totp';
|
||||
}
|
||||
|
||||
export interface MfaSetupResponse {
|
||||
method: string;
|
||||
qrCode: string;
|
||||
secret: string;
|
||||
manualKey: string;
|
||||
}
|
||||
|
||||
export interface MfaEnableRequest {
|
||||
secret: string;
|
||||
verificationCode: string;
|
||||
}
|
||||
|
||||
export interface MfaEnableResponse {
|
||||
success: boolean;
|
||||
backupCodes: string[];
|
||||
}
|
||||
|
||||
export interface MfaDisableRequest {
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
export interface MfaBackupCodesResponse {
|
||||
backupCodes: string[];
|
||||
}
|
||||
|
||||
export interface MfaAccessValidationResponse {
|
||||
valid: boolean;
|
||||
isTransferToken?: boolean;
|
||||
requiresMfaSetup?: boolean;
|
||||
userHasMfa?: boolean;
|
||||
isMfaEnforced?: boolean;
|
||||
}
|
||||
66
apps/client/src/ee/security/components/enforce-mfa.tsx
Normal file
66
apps/client/src/ee/security/components/enforce-mfa.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { Group, Text, Switch, MantineSize, Title } from "@mantine/core";
|
||||
import { useAtom } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
|
||||
export default function EnforceMfa() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title order={4} my="sm">
|
||||
MFA
|
||||
</Title>
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<Text size="md">{t("Enforce two-factor authentication")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"Once enforced, all members must enable two-factor authentication to access the workspace.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<EnforceMfaToggle />
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface EnforceMfaToggleProps {
|
||||
size?: MantineSize;
|
||||
label?: string;
|
||||
}
|
||||
export function EnforceMfaToggle({ size, label }: EnforceMfaToggleProps) {
|
||||
const { t } = useTranslation();
|
||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||
const [checked, setChecked] = useState(workspace?.enforceMfa);
|
||||
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.checked;
|
||||
try {
|
||||
const updatedWorkspace = await updateWorkspace({ enforceMfa: value });
|
||||
setChecked(value);
|
||||
setWorkspace(updatedWorkspace);
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
message: err?.response?.data?.message,
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Switch
|
||||
size={size}
|
||||
label={label}
|
||||
labelPosition="left"
|
||||
defaultChecked={checked}
|
||||
onChange={handleChange}
|
||||
aria-label={t("Toggle MFA enforcement")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -11,6 +11,7 @@ import AllowedDomains from "@/ee/security/components/allowed-domains.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLicense from "@/ee/hooks/use-license.tsx";
|
||||
import usePlan from "@/ee/hooks/use-plan.tsx";
|
||||
import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
|
||||
|
||||
export default function Security() {
|
||||
const { t } = useTranslation();
|
||||
@ -33,6 +34,10 @@ export default function Security() {
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
<EnforceMfa />
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
<Title order={4} my="lg">
|
||||
Single sign-on (SSO)
|
||||
</Title>
|
||||
|
||||
Reference in New Issue
Block a user