fix: resolve ESLint errors in MFA implementation

- Fix missing semicolons and newlines at end of files
- Remove trailing spaces and add missing trailing commas
- Fix import sorting and remove unused imports
- Fix curly brace placement and quote consistency
- Update empty arrow function in test file
- Ensure all MFA files comply with project ESLint rules
pull/22206/head
k-brahma-dify 10 months ago
parent ef596315b1
commit 1d6988c788

@ -1,13 +1,13 @@
import React from 'react' import React from 'react';
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom' import '@testing-library/jest-dom';
// Simple test component for debugging // Simple test component for debugging
const TestComponent = () => <div>MFA Test Component</div> const TestComponent = () => <div>MFA Test Component</div>;
describe('MFA Debug Test', () => { describe('MFA Debug Test', () => {
test('renders simple component', () => { test('renders simple component', () => {
render(<TestComponent />) render(<TestComponent />);
expect(screen.getByText('MFA Test Component')).toBeInTheDocument() expect(screen.getByText('MFA Test Component')).toBeInTheDocument();
}) });
}) });

@ -1,6 +1,6 @@
'use client' 'use client';
import { useTranslation } from 'react-i18next' import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next';
import { import {
RiBrain2Fill, RiBrain2Fill,
RiBrain2Line, RiBrain2Line,
@ -16,54 +16,54 @@ import {
RiPuzzle2Fill, RiPuzzle2Fill,
RiPuzzle2Line, RiPuzzle2Line,
RiTranslate2, RiTranslate2,
RiShieldKeyholeLine,
RiShieldKeyholeFill, RiShieldKeyholeFill,
} from '@remixicon/react' RiShieldKeyholeLine,
import Button from '../../base/button' } from '@remixicon/react';
import MembersPage from './members-page' import cn from '@/utils/classnames';
import LanguagePage from './language-page' import BillingPage from '@/app/components/billing/billing-page';
import MFAPage from './mfa-page' import CustomPage from '@/app/components/custom/custom-page';
import ApiBasedExtensionPage from './api-based-extension-page' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints';
import DataSourcePage from './data-source-page' import { useProviderContext } from '@/context/provider-context';
import ModelProviderPage from './model-provider-page' import { useAppContext } from '@/context/app-context';
import cn from '@/utils/classnames' import Button from '../../base/button';
import BillingPage from '@/app/components/billing/billing-page' import Input from '../../base/input';
import CustomPage from '@/app/components/custom/custom-page' import MenuDialog from './menu-dialog';
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import MembersPage from './members-page';
import { useProviderContext } from '@/context/provider-context' import LanguagePage from './language-page';
import { useAppContext } from '@/context/app-context' import MFAPage from './mfa-page';
import MenuDialog from '@/app/components/header/account-setting/menu-dialog' import ApiBasedExtensionPage from './api-based-extension-page';
import Input from '@/app/components/base/input' import DataSourcePage from './data-source-page';
import ModelProviderPage from './model-provider-page';
const iconClassName = ` const iconClassName = `
w-5 h-5 mr-2 w-5 h-5 mr-2
` `;
type IAccountSettingProps = { type IAccountSettingProps = {
onCancel: () => void onCancel: () => void;
activeTab?: string activeTab?: string;
} };
type GroupItem = { type GroupItem = {
key: string key: string;
name: string name: string;
description?: string description?: string;
icon: React.JSX.Element icon: React.JSX.Element;
activeIcon: React.JSX.Element activeIcon: React.JSX.Element;
} };
export default function AccountSetting({ export default function AccountSetting({
onCancel, onCancel,
activeTab = 'members', activeTab = 'members',
}: IAccountSettingProps) { }: IAccountSettingProps) {
const [activeMenu, setActiveMenu] = useState(activeTab) const [activeMenu, setActiveMenu] = useState(activeTab);
const { t } = useTranslation() const { t } = useTranslation();
const { enableBilling, enableReplaceWebAppLogo } = useProviderContext() const { enableBilling, enableReplaceWebAppLogo } = useProviderContext();
const { isCurrentWorkspaceDatasetOperator } = useAppContext() const { isCurrentWorkspaceDatasetOperator } = useAppContext();
const workplaceGroupItems = (() => { const workplaceGroupItems = (() => {
if (isCurrentWorkspaceDatasetOperator) if (isCurrentWorkspaceDatasetOperator)
return [] return [];
return [ return [
{ {
key: 'provider', key: 'provider',
@ -103,11 +103,11 @@ export default function AccountSetting({
icon: <RiColorFilterLine className={iconClassName} />, icon: <RiColorFilterLine className={iconClassName} />,
activeIcon: <RiColorFilterFill className={iconClassName} />, activeIcon: <RiColorFilterFill className={iconClassName} />,
}, },
].filter(item => !!item.key) as GroupItem[] ].filter(item => !!item.key) as GroupItem[];
})() })();
const media = useBreakpoints() const media = useBreakpoints();
const isMobile = media === MediaType.mobile const isMobile = media === MediaType.mobile;
const menuItems = [ const menuItems = [
{ {
@ -133,24 +133,24 @@ export default function AccountSetting({
}, },
], ],
}, },
] ];
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null);
const [scrolled, setScrolled] = useState(false) const [scrolled, setScrolled] = useState(false);
useEffect(() => { useEffect(() => {
const targetElement = scrollRef.current const targetElement = scrollRef.current;
const scrollHandle = (e: Event) => { const scrollHandle = (e: Event) => {
const userScrolled = (e.target as HTMLDivElement).scrollTop > 0 const userScrolled = (e.target as HTMLDivElement).scrollTop > 0;
setScrolled(userScrolled) setScrolled(userScrolled);
} };
targetElement?.addEventListener('scroll', scrollHandle) targetElement?.addEventListener('scroll', scrollHandle);
return () => { return () => {
targetElement?.removeEventListener('scroll', scrollHandle) targetElement?.removeEventListener('scroll', scrollHandle);
} };
}, []) }, []);
const activeItem = [...menuItems[0].items, ...menuItems[1].items].find(item => item.key === activeMenu) const activeItem = [...menuItems[0].items, ...menuItems[1].items].find(item => item.key === activeMenu);
const [searchValue, setSearchValue] = useState<string>('') const [searchValue, setSearchValue] = useState<string>('');
return ( return (
<MenuDialog <MenuDialog
@ -184,11 +184,11 @@ export default function AccountSetting({
{activeMenu === item.key ? item.activeIcon : item.icon} {activeMenu === item.key ? item.activeIcon : item.icon}
{!isMobile && <div className='truncate'>{item.name}</div>} {!isMobile && <div className='truncate'>{item.name}</div>}
</div> </div>
)) ));
} }
</div> </div>
</div> </div>
)) ));
} }
</div> </div>
</div> </div>
@ -240,5 +240,5 @@ export default function AccountSetting({
</div> </div>
</div> </div>
</MenuDialog> </MenuDialog>
) );
} }

@ -1,15 +1,15 @@
import { Fragment, useCallback, useEffect } from 'react' import { Fragment, useCallback, useEffect } from 'react';
import type { ReactNode } from 'react' import type { ReactNode } from 'react';
import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react' import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react';
import cn from '@/utils/classnames' import { noop } from 'lodash-es';
import { noop } from 'lodash-es' import cn from '@/utils/classnames';
type DialogProps = { type DialogProps = {
className?: string className?: string;
children: ReactNode children: ReactNode;
show: boolean show: boolean;
onClose?: () => void onClose?: () => void;
} };
const MenuDialog = ({ const MenuDialog = ({
className, className,
@ -17,21 +17,21 @@ const MenuDialog = ({
show, show,
onClose, onClose,
}: DialogProps) => { }: DialogProps) => {
const close = useCallback(() => onClose?.(), [onClose]) const close = useCallback(() => onClose?.(), [onClose]);
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
event.preventDefault() event.preventDefault();
close() close();
}
} }
};
document.addEventListener('keydown', handleKeyDown) document.addEventListener('keydown', handleKeyDown);
return () => { return () => {
document.removeEventListener('keydown', handleKeyDown) document.removeEventListener('keydown', handleKeyDown);
} };
}, [close]) }, [close]);
return ( return (
<Transition appear show={show} as={Fragment}> <Transition appear show={show} as={Fragment}>
@ -58,7 +58,7 @@ const MenuDialog = ({
</div> </div>
</Dialog> </Dialog>
</Transition > </Transition >
) );
} };
export default MenuDialog export default MenuDialog;

@ -1,18 +1,18 @@
import React from 'react' import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom' import '@testing-library/jest-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// import MFAPage from './mfa-page' // import MFAPage from './mfa-page';
// Temporary mock component // Temporary mock component
const MFAPage = () => <div>MFA Page Mock</div> const MFAPage = () => <div>MFA Page Mock</div>;
// Mock the translation hook // Mock the translation hook
jest.mock('react-i18next', () => ({ jest.mock('react-i18next', () => ({
useTranslation: () => ({ useTranslation: () => ({
t: (key: string) => key, t: (key: string) => key,
}), }),
})) }));
// Mock the MFA service // Mock the MFA service
jest.mock('@/service/use-mfa', () => ({ jest.mock('@/service/use-mfa', () => ({
@ -22,7 +22,7 @@ jest.mock('@/service/use-mfa', () => ({
setupComplete: jest.fn(), setupComplete: jest.fn(),
disable: jest.fn(), disable: jest.fn(),
}, },
})) }));
// Mock the Toast component // Mock the Toast component
jest.mock('@/app/components/base/toast', () => ({ jest.mock('@/app/components/base/toast', () => ({
@ -31,7 +31,7 @@ jest.mock('@/app/components/base/toast', () => ({
error: jest.fn(), error: jest.fn(),
success: jest.fn(), success: jest.fn(),
}, },
})) }));
// Mock useRouter // Mock useRouter
jest.mock('next/navigation', () => ({ jest.mock('next/navigation', () => ({
@ -40,39 +40,39 @@ jest.mock('next/navigation', () => ({
replace: jest.fn(), replace: jest.fn(),
refresh: jest.fn(), refresh: jest.fn(),
}), }),
})) }));
// Mock Modal component to avoid Portal issues // Mock Modal component to avoid Portal issues
jest.mock('@/app/components/base/modal', () => ({ jest.mock('@/app/components/base/modal', () => ({
__esModule: true, __esModule: true,
default: ({ children, isShow }: any) => isShow ? <div data-testid="modal">{children}</div> : null, default: ({ children, isShow }: any) => isShow ? <div data-testid="modal">{children}</div> : null,
})) }));
describe('MFAPage Component', () => { describe('MFAPage Component', () => {
let queryClient: QueryClient let queryClient: QueryClient;
beforeEach(() => { beforeEach(() => {
queryClient = new QueryClient({ queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { retry: false }, queries: { retry: false },
}, },
}) });
jest.clearAllMocks() jest.clearAllMocks();
}) });
const renderMFAPage = () => { const renderMFAPage = () => {
return render( return render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<MFAPage /> <MFAPage />
</QueryClientProvider> </QueryClientProvider>
) );
} };
test('renders mock component', () => { test('renders mock component', () => {
renderMFAPage() renderMFAPage();
expect(screen.getByText('MFA Page Mock')).toBeInTheDocument() expect(screen.getByText('MFA Page Mock')).toBeInTheDocument();
}) });
// Other tests disabled for now to test core functionality // Other tests disabled for now to test core functionality
}) });

@ -1,15 +1,15 @@
import React from 'react' import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom' import '@testing-library/jest-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import MFAPage from './mfa-page' import MFAPage from './mfa-page';
// Mock the translation hook // Mock the translation hook
jest.mock('react-i18next', () => ({ jest.mock('react-i18next', () => ({
useTranslation: () => ({ useTranslation: () => ({
t: (key: string) => key, t: (key: string) => key,
}), }),
})) }));
// Mock the MFA service // Mock the MFA service
jest.mock('@/service/use-mfa', () => ({ jest.mock('@/service/use-mfa', () => ({
@ -19,7 +19,7 @@ jest.mock('@/service/use-mfa', () => ({
setupComplete: jest.fn(), setupComplete: jest.fn(),
disable: jest.fn(), disable: jest.fn(),
}, },
})) }));
// Mock the Toast component // Mock the Toast component
jest.mock('@/app/components/base/toast', () => ({ jest.mock('@/app/components/base/toast', () => ({
@ -28,177 +28,177 @@ jest.mock('@/app/components/base/toast', () => ({
error: jest.fn(), error: jest.fn(),
success: jest.fn(), success: jest.fn(),
}, },
})) }));
describe('MFAPage Component', () => { describe('MFAPage Component', () => {
let queryClient: QueryClient let queryClient: QueryClient;
beforeEach(() => { beforeEach(() => {
queryClient = new QueryClient({ queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { retry: false }, queries: { retry: false },
}, },
}) });
}) });
const renderMFAPage = () => { const renderMFAPage = () => {
return render( return render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<MFAPage /> <MFAPage />
</QueryClientProvider> </QueryClientProvider>
) );
} };
test('renders loading state initially', () => { test('renders loading state initially', () => {
const { mfaService } = require('@/service/use-mfa') const { mfaService } = require('@/service/use-mfa');
mfaService.getStatus.mockImplementation(() => new Promise(() => {})) // Never resolves mfaService.getStatus.mockImplementation(() => new Promise(() => {})); // Never resolves
renderMFAPage() renderMFAPage();
expect(screen.getByText('Loading...')).toBeInTheDocument() expect(screen.getByText('Loading...')).toBeInTheDocument();
}) });
test('renders enable button when MFA is disabled', async () => { test('renders enable button when MFA is disabled', async () => {
const { mfaService } = require('@/service/use-mfa') const { mfaService } = require('@/service/use-mfa');
mfaService.getStatus.mockResolvedValue({ enabled: false }) mfaService.getStatus.mockResolvedValue({ enabled: false });
renderMFAPage() renderMFAPage();
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('common.settings.mfaEnable')).toBeInTheDocument() expect(screen.getByText('common.settings.mfaEnable')).toBeInTheDocument();
}) });
}) });
test('renders disable button when MFA is enabled', async () => { test('renders disable button when MFA is enabled', async () => {
const { mfaService } = require('@/service/use-mfa') const { mfaService } = require('@/service/use-mfa');
mfaService.getStatus.mockResolvedValue({ mfaService.getStatus.mockResolvedValue({
enabled: true, enabled: true,
setup_at: '2025-01-01T12:00:00' setup_at: '2025-01-01T12:00:00',
}) });
renderMFAPage() renderMFAPage();
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('common.settings.mfaDisable')).toBeInTheDocument() expect(screen.getByText('common.settings.mfaDisable')).toBeInTheDocument();
}) });
}) });
test('opens setup modal when enable button is clicked', async () => { test('opens setup modal when enable button is clicked', async () => {
const { mfaService } = require('@/service/use-mfa') const { mfaService } = require('@/service/use-mfa');
mfaService.getStatus.mockResolvedValue({ enabled: false }) mfaService.getStatus.mockResolvedValue({ enabled: false });
mfaService.setupInit.mockResolvedValue({ mfaService.setupInit.mockResolvedValue({
secret: 'TEST_SECRET', secret: 'TEST_SECRET',
qr_code: 'data:image/png;base64,test' qr_code: 'data:image/png;base64,test',
}) });
renderMFAPage() renderMFAPage();
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('common.settings.mfaEnable')).toBeInTheDocument() expect(screen.getByText('common.settings.mfaEnable')).toBeInTheDocument();
}) });
fireEvent.click(screen.getByText('common.settings.mfaEnable')) fireEvent.click(screen.getByText('common.settings.mfaEnable'));
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('mfa.setup.title')).toBeInTheDocument() expect(screen.getByText('mfa.setup.title')).toBeInTheDocument();
}) });
}) });
test('completes MFA setup successfully', async () => { test('completes MFA setup successfully', async () => {
const { mfaService } = require('@/service/use-mfa') const { mfaService } = require('@/service/use-mfa');
const Toast = require('@/app/components/base/toast').default const Toast = require('@/app/components/base/toast').default;
mfaService.getStatus.mockResolvedValue({ enabled: false }) mfaService.getStatus.mockResolvedValue({ enabled: false });
mfaService.setupInit.mockResolvedValue({ mfaService.setupInit.mockResolvedValue({
secret: 'TEST_SECRET', secret: 'TEST_SECRET',
qr_code: 'data:image/png;base64,test' qr_code: 'data:image/png;base64,test',
}) });
mfaService.setupComplete.mockResolvedValue({ mfaService.setupComplete.mockResolvedValue({
message: 'Success', message: 'Success',
backup_codes: ['CODE1', 'CODE2', 'CODE3'] backup_codes: ['CODE1', 'CODE2', 'CODE3'],
}) });
renderMFAPage() renderMFAPage();
// Click enable // Click enable
await waitFor(() => { await waitFor(() => {
fireEvent.click(screen.getByText('common.settings.mfaEnable')) fireEvent.click(screen.getByText('common.settings.mfaEnable'));
}) });
// Enter TOTP code // Enter TOTP code
await waitFor(() => { await waitFor(() => {
const input = screen.getByPlaceholderText('mfa.setup.totpPlaceholder') const input = screen.getByPlaceholderText('mfa.setup.totpPlaceholder');
fireEvent.change(input, { target: { value: '123456' } }) fireEvent.change(input, { target: { value: '123456' } });
}) });
// Click next // Click next
fireEvent.click(screen.getByText('common.operation.next')) fireEvent.click(screen.getByText('common.operation.next'));
await waitFor(() => { await waitFor(() => {
expect(Toast.success).toHaveBeenCalledWith('mfa.setup.success') expect(Toast.success).toHaveBeenCalledWith('mfa.setup.success');
}) });
}) });
test('shows error when setup fails', async () => { test('shows error when setup fails', async () => {
const { mfaService } = require('@/service/use-mfa') const { mfaService } = require('@/service/use-mfa');
const Toast = require('@/app/components/base/toast').default const Toast = require('@/app/components/base/toast').default;
mfaService.getStatus.mockResolvedValue({ enabled: false }) mfaService.getStatus.mockResolvedValue({ enabled: false });
mfaService.setupInit.mockResolvedValue({ mfaService.setupInit.mockResolvedValue({
secret: 'TEST_SECRET', secret: 'TEST_SECRET',
qr_code: 'data:image/png;base64,test' qr_code: 'data:image/png;base64,test',
}) });
mfaService.setupComplete.mockRejectedValue(new Error('Invalid TOTP')) mfaService.setupComplete.mockRejectedValue(new Error('Invalid TOTP'));
renderMFAPage() renderMFAPage();
// Click enable // Click enable
await waitFor(() => { await waitFor(() => {
fireEvent.click(screen.getByText('common.settings.mfaEnable')) fireEvent.click(screen.getByText('common.settings.mfaEnable'));
}) });
// Enter TOTP code // Enter TOTP code
await waitFor(() => { await waitFor(() => {
const input = screen.getByPlaceholderText('mfa.setup.totpPlaceholder') const input = screen.getByPlaceholderText('mfa.setup.totpPlaceholder');
fireEvent.change(input, { target: { value: 'wrong' } }) fireEvent.change(input, { target: { value: 'wrong' } });
}) });
// Click next // Click next
fireEvent.click(screen.getByText('common.operation.next')) fireEvent.click(screen.getByText('common.operation.next'));
await waitFor(() => { await waitFor(() => {
expect(Toast.error).toHaveBeenCalledWith('Invalid TOTP') expect(Toast.error).toHaveBeenCalledWith('Invalid TOTP');
}) });
}) });
test('disables MFA successfully', async () => { test('disables MFA successfully', async () => {
const { mfaService } = require('@/service/use-mfa') const { mfaService } = require('@/service/use-mfa');
const Toast = require('@/app/components/base/toast').default const Toast = require('@/app/components/base/toast').default;
mfaService.getStatus.mockResolvedValue({ mfaService.getStatus.mockResolvedValue({
enabled: true, enabled: true,
setup_at: '2025-01-01T12:00:00' setup_at: '2025-01-01T12:00:00',
}) });
mfaService.disable.mockResolvedValue({ message: 'Success' }) mfaService.disable.mockResolvedValue({ message: 'Success' });
renderMFAPage() renderMFAPage();
// Click disable // Click disable
await waitFor(() => { await waitFor(() => {
fireEvent.click(screen.getByText('common.settings.mfaDisable')) fireEvent.click(screen.getByText('common.settings.mfaDisable'));
}) });
// Enter password // Enter password
await waitFor(() => { await waitFor(() => {
const input = screen.getByPlaceholderText('mfa.disable.passwordPlaceholder') const input = screen.getByPlaceholderText('mfa.disable.passwordPlaceholder');
fireEvent.change(input, { target: { value: 'password123' } }) fireEvent.change(input, { target: { value: 'password123' } });
}) });
// Click confirm // Click confirm
fireEvent.click(screen.getByText('common.operation.confirm')) fireEvent.click(screen.getByText('common.operation.confirm'));
await waitFor(() => { await waitFor(() => {
expect(Toast.success).toHaveBeenCalledWith('mfa.disable.success') expect(Toast.success).toHaveBeenCalledWith('mfa.disable.success');
}) });
}) });
}) });

@ -1,40 +1,40 @@
'use client' 'use client';
import { useState } from 'react' import { useState } from 'react';
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next';
import { RiShieldKeyholeLine, RiCheckboxCircleFill, RiLoader2Line } from '@remixicon/react' import { RiShieldKeyholeLine, RiCheckboxCircleFill, RiLoader2Line } from '@remixicon/react';
import Toast from '../../base/toast' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import Button from '../../base/button' import Toast from '../../base/toast';
import Input from '../../base/input' import Button from '../../base/button';
import Modal from '../../base/modal' import Input from '../../base/input';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import Modal from '../../base/modal';
// API service functions // API service functions
const mfaService = { const mfaService = {
getStatus: async () => { getStatus: async () => {
const token = localStorage.getItem('console_token') const token = localStorage.getItem('console_token');
const response = await fetch('/console/api/account/mfa/status', { const response = await fetch('/console/api/account/mfa/status', {
headers: { headers: {
'Authorization': `Bearer ${token}`, 'Authorization': `Bearer ${token}`,
}, },
}) });
if (!response.ok) throw new Error('Failed to fetch MFA status') if (!response.ok) throw new Error('Failed to fetch MFA status');
return response.json() return response.json();
}, },
initSetup: async () => { initSetup: async () => {
const token = localStorage.getItem('console_token') const token = localStorage.getItem('console_token');
const response = await fetch('/console/api/account/mfa/setup', { const response = await fetch('/console/api/account/mfa/setup', {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${token}`, 'Authorization': `Bearer ${token}`,
}, },
}) });
if (!response.ok) throw new Error('Failed to initialize MFA setup') if (!response.ok) throw new Error('Failed to initialize MFA setup');
return response.json() return response.json();
}, },
completeSetup: async (totpToken: string, password: string) => { completeSetup: async (totpToken: string, password: string) => {
const token = localStorage.getItem('console_token') const token = localStorage.getItem('console_token');
const response = await fetch('/console/api/account/mfa/setup/complete', { const response = await fetch('/console/api/account/mfa/setup/complete', {
method: 'POST', method: 'POST',
headers: { headers: {
@ -42,13 +42,13 @@ const mfaService = {
'Authorization': `Bearer ${token}`, 'Authorization': `Bearer ${token}`,
}, },
body: JSON.stringify({ totp_token: totpToken }), body: JSON.stringify({ totp_token: totpToken }),
}) });
if (!response.ok) throw new Error('Failed to complete MFA setup') if (!response.ok) throw new Error('Failed to complete MFA setup');
return response.json() return response.json();
}, },
disable: async (password: string) => { disable: async (password: string) => {
const token = localStorage.getItem('console_token') const token = localStorage.getItem('console_token');
const response = await fetch('/console/api/account/mfa/disable', { const response = await fetch('/console/api/account/mfa/disable', {
method: 'POST', method: 'POST',
headers: { headers: {
@ -56,98 +56,98 @@ const mfaService = {
'Authorization': `Bearer ${token}`, 'Authorization': `Bearer ${token}`,
}, },
body: JSON.stringify({ password }), body: JSON.stringify({ password }),
}) });
if (!response.ok) throw new Error('Failed to disable MFA') if (!response.ok) throw new Error('Failed to disable MFA');
return response.json() return response.json();
}, },
} };
export default function MFAPage() { export default function MFAPage() {
const { t } = useTranslation() const { t } = useTranslation();
const queryClient = useQueryClient() const queryClient = useQueryClient();
// State // State
const [isSetupModalOpen, setIsSetupModalOpen] = useState(false) const [isSetupModalOpen, setIsSetupModalOpen] = useState(false);
const [isDisableModalOpen, setIsDisableModalOpen] = useState(false) const [isDisableModalOpen, setIsDisableModalOpen] = useState(false);
const [setupStep, setSetupStep] = useState<'qr' | 'verify' | 'backup'>('qr') const [setupStep, setSetupStep] = useState<'qr' | 'verify' | 'backup'>('qr');
const [totpToken, setTotpToken] = useState('') const [totpToken, setTotpToken] = useState('');
const [password, setPassword] = useState('') const [password, setPassword] = useState('');
const [qrData, setQrData] = useState<{ secret: string; qr_code: string } | null>(null) const [qrData, setQrData] = useState<{ secret: string; qr_code: string } | null>(null);
const [backupCodes, setBackupCodes] = useState<string[]>([]) const [backupCodes, setBackupCodes] = useState<string[]>([]);
// Query MFA status // Query MFA status
const { data: mfaStatus, isLoading } = useQuery({ const { data: mfaStatus, isLoading } = useQuery({
queryKey: ['mfa-status'], queryKey: ['mfa-status'],
queryFn: mfaService.getStatus, queryFn: mfaService.getStatus,
}) });
// Mutations // Mutations
const initSetupMutation = useMutation({ const initSetupMutation = useMutation({
mutationFn: mfaService.initSetup, mutationFn: mfaService.initSetup,
onSuccess: (data) => { onSuccess: (data) => {
setQrData(data) setQrData(data);
setIsSetupModalOpen(true) setIsSetupModalOpen(true);
setSetupStep('qr') setSetupStep('qr');
}, },
onError: () => { onError: () => {
Toast.notify({ type: 'error', message: t('common.somethingWentWrong') }) Toast.notify({ type: 'error', message: t('common.somethingWentWrong') });
}, },
}) });
const completeSetupMutation = useMutation({ const completeSetupMutation = useMutation({
mutationFn: ({ totpToken, password }: { totpToken: string; password: string }) => mutationFn: ({ totpToken, password }: { totpToken: string; password: string }) =>
mfaService.completeSetup(totpToken, password), mfaService.completeSetup(totpToken, password),
onSuccess: (data) => { onSuccess: (data) => {
setBackupCodes(data.backup_codes) setBackupCodes(data.backup_codes);
setSetupStep('backup') setSetupStep('backup');
queryClient.invalidateQueries({ queryKey: ['mfa-status'] }) queryClient.invalidateQueries({ queryKey: ['mfa-status'] });
}, },
onError: () => { onError: () => {
Toast.notify({ type: 'error', message: t('mfa.invalidToken') }) Toast.notify({ type: 'error', message: t('mfa.invalidToken') });
}, },
}) });
const disableMutation = useMutation({ const disableMutation = useMutation({
mutationFn: mfaService.disable, mutationFn: mfaService.disable,
onSuccess: () => { onSuccess: () => {
setIsDisableModalOpen(false) setIsDisableModalOpen(false);
queryClient.invalidateQueries({ queryKey: ['mfa-status'] }) queryClient.invalidateQueries({ queryKey: ['mfa-status'] });
Toast.notify({ type: 'success', message: t('mfa.disabledSuccess') }) Toast.notify({ type: 'success', message: t('mfa.disabledSuccess') });
}, },
onError: () => { onError: () => {
Toast.notify({ type: 'error', message: t('mfa.invalidPassword') }) Toast.notify({ type: 'error', message: t('mfa.invalidPassword') });
}, },
}) });
const handleSetupStart = () => { const handleSetupStart = () => {
initSetupMutation.mutate() initSetupMutation.mutate();
} };
const handleVerifyToken = () => { const handleVerifyToken = () => {
if (totpToken.length !== 6) { if (totpToken.length !== 6) {
Toast.notify({ type: 'error', message: t('mfa.tokenLength') }) Toast.notify({ type: 'error', message: t('mfa.tokenLength') });
return return;
}
completeSetupMutation.mutate({ totpToken, password: '' })
} }
completeSetupMutation.mutate({ totpToken, password: '' });
};
const handleDisable = () => { const handleDisable = () => {
disableMutation.mutate(password) disableMutation.mutate(password);
} };
const handleCopyBackupCodes = () => { const handleCopyBackupCodes = () => {
const codesText = backupCodes.join('\n') const codesText = backupCodes.join('\n');
navigator.clipboard.writeText(codesText) navigator.clipboard.writeText(codesText);
Toast.notify({ type: 'success', message: t('mfa.copied') }) Toast.notify({ type: 'success', message: t('mfa.copied') });
} };
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center h-96"> <div className="flex items-center justify-center h-96">
<RiLoader2Line className="animate-spin w-6 h-6 text-text-tertiary" /> <RiLoader2Line className="animate-spin w-6 h-6 text-text-tertiary" />
</div> </div>
) );
} }
return ( return (
@ -274,8 +274,8 @@ export default function MFAPage() {
variant="primary" variant="primary"
className="flex-1" className="flex-1"
onClick={() => { onClick={() => {
setIsSetupModalOpen(false) setIsSetupModalOpen(false);
Toast.notify({ type: 'success', message: t('mfa.enabledSuccess') }) Toast.notify({ type: 'success', message: t('mfa.enabledSuccess') });
}} }}
> >
{t('mfa.done')} {t('mfa.done')}
@ -321,5 +321,5 @@ export default function MFAPage() {
</div> </div>
</Modal> </Modal>
</div> </div>
) );
} }

@ -1,38 +1,38 @@
import { useState } from 'react' import { useState } from 'react';
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next';
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation';
import { RiShieldKeyholeLine } from '@remixicon/react' import { RiShieldKeyholeLine } from '@remixicon/react';
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button';
import Toast from '@/app/components/base/toast' import Toast from '@/app/components/base/toast';
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input';
import { login } from '@/service/common' import { login } from '@/service/common';
type MFAVerificationProps = { type MFAVerificationProps = {
email: string email: string;
password: string password: string;
inviteToken?: string inviteToken?: string;
isInvite: boolean isInvite: boolean;
locale: string locale: string;
} };
export default function MFAVerification({ email, password, inviteToken, isInvite, locale }: MFAVerificationProps) { export default function MFAVerification({ email, password, inviteToken, isInvite, locale }: MFAVerificationProps) {
const { t } = useTranslation() const { t } = useTranslation();
const router = useRouter() const router = useRouter();
const [mfaCode, setMfaCode] = useState('') const [mfaCode, setMfaCode] = useState('');
const [useBackupCode, setUseBackupCode] = useState(false) const [useBackupCode, setUseBackupCode] = useState(false);
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false);
const handleMFAVerification = async () => { const handleMFAVerification = async () => {
if (!mfaCode || mfaCode.length !== (useBackupCode ? 8 : 6)) { if (!mfaCode || mfaCode.length !== (useBackupCode ? 8 : 6)) {
Toast.notify({ Toast.notify({
type: 'error', type: 'error',
message: t(useBackupCode ? 'mfa.backupCode' : 'mfa.tokenLength') message: t(useBackupCode ? 'mfa.backupCode' : 'mfa.tokenLength'),
}) });
return return;
} }
try { try {
setIsLoading(true) setIsLoading(true);
const loginData: Record<string, any> = { const loginData: Record<string, any> = {
email, email,
password, password,
@ -40,42 +40,42 @@ export default function MFAVerification({ email, password, inviteToken, isInvite
is_backup_code: useBackupCode, is_backup_code: useBackupCode,
language: locale, language: locale,
remember_me: true, remember_me: true,
} };
if (isInvite && inviteToken) if (isInvite && inviteToken)
loginData.invite_token = inviteToken loginData.invite_token = inviteToken;
console.log('Sending MFA login request:', loginData) console.log('Sending MFA login request:', loginData);
const res = await login({ const res = await login({
url: '/login', url: '/login',
body: loginData, body: loginData,
}) });
console.log('MFA login response:', res) console.log('MFA login response:', res);
if (res.result === 'success') { if (res.result === 'success') {
if (isInvite) { if (isInvite) {
const params = new URLSearchParams() const params = new URLSearchParams();
if (inviteToken) if (inviteToken)
params.append('invite_token', inviteToken) params.append('invite_token', inviteToken);
router.replace(`/signin/invite-settings?${params.toString()}`) router.replace(`/signin/invite-settings?${params.toString()}`);
} }
else { else {
localStorage.setItem('console_token', res.data.access_token) localStorage.setItem('console_token', res.data.access_token);
localStorage.setItem('refresh_token', res.data.refresh_token) localStorage.setItem('refresh_token', res.data.refresh_token);
router.replace('/apps') router.replace('/apps');
} }
} }
else { else {
Toast.notify({ Toast.notify({
type: 'error', type: 'error',
message: res.data || t('mfa.invalidToken'), message: res.data || t('mfa.invalidToken'),
}) });
} }
} }
finally { finally {
setIsLoading(false) setIsLoading(false);
}
} }
};
return ( return (
<div className="w-full"> <div className="w-full">
@ -102,7 +102,7 @@ export default function MFAVerification({ email, password, inviteToken, isInvite
onChange={e => setMfaCode(e.target.value.replace(/\D/g, ''))} onChange={e => setMfaCode(e.target.value.replace(/\D/g, ''))}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') if (e.key === 'Enter')
handleMFAVerification() handleMFAVerification();
}} }}
placeholder={useBackupCode ? '12345678' : '123456'} placeholder={useBackupCode ? '12345678' : '123456'}
maxLength={useBackupCode ? 8 : 6} maxLength={useBackupCode ? 8 : 6}
@ -124,8 +124,8 @@ export default function MFAVerification({ email, password, inviteToken, isInvite
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setUseBackupCode(!useBackupCode) setUseBackupCode(!useBackupCode);
setMfaCode('') setMfaCode('');
}} }}
className="system-xs-medium text-components-button-secondary-accent-text hover:underline" className="system-xs-medium text-components-button-secondary-accent-text hover:underline"
> >
@ -133,5 +133,5 @@ export default function MFAVerification({ email, password, inviteToken, isInvite
</button> </button>
</div> </div>
</div> </div>
) );
} }

@ -30,6 +30,6 @@ const translation = {
copy: 'Kopieren', copy: 'Kopieren',
copied: 'Kopiert', copied: 'Kopiert',
done: 'Fertig', done: 'Fertig',
} };
export default translation export default translation;

@ -30,6 +30,6 @@ const translation = {
copy: 'Copy', copy: 'Copy',
copied: 'Copied', copied: 'Copied',
done: 'Done', done: 'Done',
} };
export default translation export default translation;

@ -31,6 +31,6 @@ const translation = {
copy: 'コピー', copy: 'コピー',
copied: 'コピー完了', copied: 'コピー完了',
done: '完了', done: '完了',
} };
export default translation export default translation;

@ -30,6 +30,6 @@ const translation = {
copy: '复制', copy: '复制',
copied: '已复制', copied: '已复制',
done: '完成', done: '完成',
} };
export default translation export default translation;

@ -1,29 +1,29 @@
import { get, post } from './base' import { get, post } from './base';
export const getMFAStatus = () => { export const getMFAStatus = () => {
return get<{ return get<{
enabled: boolean enabled: boolean;
setup_at: string | null setup_at: string | null;
}>('/console/api/account/mfa/status') }>('/console/api/account/mfa/status');
} };
export const setupMFA = () => { export const setupMFA = () => {
return post<{ return post<{
secret: string secret: string;
qr_code: string qr_code: string;
}>('/console/api/account/mfa/setup') }>('/console/api/account/mfa/setup');
} };
export const verifyMFA = (data: { token: string; password: string }) => { export const verifyMFA = (data: { token: string; password: string }) => {
return post<{ return post<{
backup_codes: string[] backup_codes: string[];
}>('/console/api/account/mfa/verify', { }>('/console/api/account/mfa/verify', {
body: data, body: data,
}) });
} };
export const disableMFA = (data: { password: string }) => { export const disableMFA = (data: { password: string }) => {
return post('/console/api/account/mfa/disable', { return post('/console/api/account/mfa/disable', {
body: data, body: data,
}) });
} };

Loading…
Cancel
Save