🚀 feat: launch v1.0 — next-generation frontend built from the ground up (#4265)

* feat: add parameter coverage for the operations: copy, trim_prefix, trim_suffix, ensure_prefix, ensure_suffix, trim_space, to_lower, to_upper, replace, and regex_replace

* fix: CrossGroupRetry default false

移除gorm:"default:false",避免每次 AutoMigrate时都执行ALTER TABLE `tokens` MODIFY COLUMN `cross_group_retry` boolean DEFAULT false
且bool默认false不影响原有功能

* feat: check-in feature integrates Turnstile security check

* feat: add support for Doubao /v1/responses (#2567)

* feat: add support for Doubao /v1/responses

* fix: fix model deployment style issues, lint problems, and i18n gaps. (#2556)

* fix: fix model deployment style issues, lint problems, and i18n gaps.

* fix: adjust the key not to be displayed on the frontend, tested via the backend.

* fix: adjust the sidebar configuration logic to use the default configuration items if they are not defined.

* feat: add plans directory to .gitignore

* fix: 修复 gemini 文件类型不支持 image/jpg

* fix: fix the proxyURL is empty, not using the default HTTP client configuration && the AWS calling side did not apply the relay timeout.

* fix: batch add key backend deduplication

* Merge pull request #2582 from seefs001/fix/tips

fix: add tips for model management and channel testing

* fix(gin): update request body size check to allow zero limit

* feat: add regex pattern to mask API keys in sensitive information

* fix(task): 修复使用 auto 分组时 Task Relay 不记录日志和不扣费的问题

问题描述:
- 使用 auto 分组的令牌调用 /v1/videos 等 Task 接口时,虽然任务能成功创建,
  但使用日志不显示记录,且不会扣费

根本原因:
- Distribute 中间件在选择渠道后,会将实际选中的分组存储在 ContextKeyAutoGroup 中
- 但 RelayTaskSubmit 函数没有从 context 中读取这个值来更新 info.UsingGroup
- 导致 info.UsingGroup 始终是 "auto" 而不是实际选中的分组(如 "sora2逆")
- 当 auto 分组的倍率配置为 0 时,quota 计算结果为 0
- 日志记录条件 "if quota != 0" 不满足,导致日志不记录、不扣费

修复方案:
- 在 RelayTaskSubmit 函数中计算分组倍率之前,添加从 ContextKeyAutoGroup
  获取实际分组的逻辑
- 使用安全的类型断言,避免潜在的 panic 风险

影响范围:
- 仅影响 Task Relay 流程(/v1/videos, /suno, /kling 等接口)
- 不影响使用具体分组令牌的调用
- 不影响其他 Relay 类型(chat/completions 等已有类似处理逻辑)

* 🚀 feat(web): port legacy v2 frontend changes into new UI (deployments, check-in, ollama) + align APIs

Bring over the key frontend functionality introduced in merge `efa3301` and integrate it cleanly into the new `web/src` architecture and design system.

- **Model deployments (io.net)**
  - Align frontend endpoints and payloads with backend deployment routes (`/api/deployments/*`)
  - Add missing deployment operations: details, logs (container-aware), update config, rename, extend duration
  - Improve create-deployment flow (proper request shape, name availability check, price estimation parity)

- **System settings**
  - Enhance io.net deployment settings: allow testing connection with an unsaved API key and add “how to get API key” guidance

- **Channels / Ollama**
  - Improve Ollama model management: live fetch via base_url with fallback to channel fetch, selection + apply flows, delete confirmation
  - Refactor for feature-layer consistency: extract Ollama parsing/normalization utilities into `features/channels/lib`

- **Quality**
  - Ensure TypeScript typecheck passes after refactor and new dialogs/components integration

* Merge pull request #2590 from xyfacai/fix/max-body-limit

fix: 设置默认max req body 为128MB

* docs: update readme

* i18n: add missing translations

* fix(gemini): fetch model list via native v1beta/models endpoint

Use the native Gemini Models API (/v1beta/models) instead of the OpenAI-compatible
path when listing models for Gemini channels, improving compatibility with
third-party Gemini-format providers that don't implement OpenAI routes.

- Add paginated model listing with timeout and optional proxy support
- Select an enabled key for multi-key Gemini channels

* refactor(gemini): 更新 GeminiModelsResponse 以使用 dto.GeminiModel 类型

* fix: remove Minimax from FETCHABLE channels

* fix(minimax): 添加 MiniMax-M2 系列模型到 ModelList

* feat: add doubao video 1.5

* 🤢 chore: remove useless file

* feat: /v1/chat/completion -> /v1/response (#2629)

* feat: /v1/chat/completion -> /v1/response

* fix: clean propertyNames for gemini function

* fix: support snake_case fields in GeminiChatGenerationConfig

* chore: update dependencies and lockfile for improved compatibility

- Updated @clerk/clerk-react to version 5.59.3
- Updated @hookform/resolvers to version 5.2.2
- Updated @lobehub/icons to version 2.48.0
- Updated various Radix UI components to their latest versions
- Updated @tanstack/react-query and related packages for better performance
- Updated axios, i18next, and other libraries for security and feature enhancements
- Updated lockfile to include configVersion and ensure consistency across environments

* Merge pull request #2647 from seefs001/feature/status-code-auto-disable

feat: status code auto-disable configuration

* fix: chat2response setting ui (#2643)

* fix: setting ui

* fix: rm global.chat_completions_to_responses_policy

* fix: rm global.chat_completions_to_responses_policy

* Merge pull request #2627 from seefs001/feature/channel-test-param-override

feat: channel testing supports parameter overriding

* chore: update dependencies and lockfile for improved compatibility

- Updated @lobehub/icons to version 4.0.3
- Updated ai to version 6.0.27
- Updated various libraries including axios, react-day-picker, and streamdown for security and feature enhancements
- Updated devDependencies for eslint, prettier, and typescript for better performance and compatibility
- Updated lockfile to ensure consistency across environments

* chore: update lockfile and Vite configuration for improved build process

- Updated lockfile to version 1 for better compatibility and consistency
- Enhanced Vite configuration to support production optimizations, including code minification and chunking for dependencies
- Added environment-specific console and debugger removal for production builds

* chore: migrate from Vite to Rsbuild for build process

- Added Rsbuild configuration for development and production builds
- Updated package.json scripts to use Rsbuild instead of Vite
- Replaced @tailwindcss/vite with @tailwindcss/postcss in dependencies
- Introduced postcss.config.mjs for Tailwind CSS integration
- Updated TypeScript configuration to include Rsbuild config
- Removed Vite configuration file to streamline the build process

* refactor: optimize user data handling and API calls

- Replaced direct API calls to get user data with cached user information from auth-store in ModelsFilter and SummaryCards components.
- Improved session management in RootComponent and Authenticated route to utilize localStorage for user authentication status, reducing unnecessary API requests.
- Added caching for setup status checks to enhance performance during navigation.

* feat: enhance session validation in authenticated route

- Implemented session verification to check user authentication status via API call only once per session.
- Updated beforeLoad logic to redirect users to the login page if session validation fails or if no user information is available in localStorage.
- Improved user data handling by updating the auth store with fresh user information upon successful session verification.

* refactor: improve useMediaQuery hook for better SSR handling

- Enhanced the useMediaQuery hook to check for window availability before accessing matchMedia, preventing errors during server-side rendering.
- Simplified state initialization and change handling by using a dedicated function to determine initial matches.
- Updated event listener management for improved performance and clarity.

* feat(hooks): export useMediaQuery from hooks index

* refactor: update useMediaQuery imports to use unified hooks index

* fix(rsbuild): fix loadEnv API usage and removeConsole type

* feat: customizable automatic retry status codes

* refactor(hooks): use useSyncExternalStore for better SSR handling in useMediaQuery

* refactor: simplify embedded file structure in main.go

- Updated the embedded file directive to include the entire web/dist directory instead of individual assets, streamlining the build process.

* refactor: replace DropdownMenu with Sheet component in ProfileDropdown

- Updated the ProfileDropdown component to use a Sheet for user interactions instead of a DropdownMenu.
- Enhanced user info display with improved layout and styling.
- Added navigation links and sign-out functionality within the Sheet.

* refactor: streamline ProfileDropdown layout and improve user info display

- Removed unused Badge component and secondary text from user display.
- Enhanced styling for user info section and navigation links.
- Updated sign-out functionality to use a button for better accessibility.

* feat: add System Settings link for super admin in ProfileDropdown

- Introduced a new link to System Settings in the ProfileDropdown, visible only to users with the SUPER_ADMIN role.
- Updated imports to include the Settings icon and adjusted the component logic accordingly.
- Removed the Settings entry from the sidebar data to streamline navigation.

* feat: codex channel (#2652)

* feat: codex channel

* feat: codex channel

* feat: codex oauth flow

* feat: codex refresh cred

* feat: codex usage

* fix: codex err message detail

* fix: codex setting ui

* feat: codex refresh cred task

* fix: import err

* fix: codex store must be false

* fix: chat -> responses tool call

* fix: chat -> responses tool call

* feat(i18n): add missing translations

* fix(i18n): restore missing translations for "360" and add "User Menu" in multiple locales

- Reintroduced the translation for "360" in English, French, Japanese, Russian, Vietnamese, and Chinese locales.
- Added the "User Menu" translation in the same languages to enhance user interface consistency.

* fix: openAI function to gemini function field adjusted to whitelist mode

* feat: TLS_INSECURE_SKIP_VERIFY env

* fix: for chat-based calls to the Claude model, tagging is required. Using Claude's rendering logs, the two approaches handle input rendering differently.

* refactor(system-settings): restructure settings sections and navigation

- Replaced SettingsAccordion with a unified SettingsSection component across various settings sections for consistency.
- Introduced a section registry to manage general settings sections dynamically.
- Updated navigation items in the system settings sidebar to utilize the new section registry.
- Enhanced the GeneralSettings component to support section-based content rendering based on user selection.

* fix(system-settings): remove type assertion for quotaDisplayType in GeneralSettings

- Eliminated the type assertion for quotaDisplayType in the GeneralSettings component to improve type inference and maintain cleaner code.

* refactor(system-settings): update zod import syntax in general settings

- Changed the import statement for zod from a default import to a namespace import for better clarity and consistency in the codebase.

* fix: the login method cannot be displayed under the aff link.

* feat(system-settings): implement generic settings page and enhance navigation

- Added a new generic SettingsPage component to handle loading states, data fetching, and section rendering.
- Integrated section registry for general and authentication settings to streamline navigation and content management.
- Updated URL utility functions to improve query parameter handling for active navigation states.
- Enhanced the system settings sidebar to include authentication section items dynamically.

* refactor(system-settings): replace SettingsAccordion with SettingsSection across authentication settings

- Updated BasicAuthSection, BotProtectionSection, OAuthSection, and PasskeySection to use the new SettingsSection component for consistency.
- Introduced a section registry to manage authentication settings dynamically, enhancing navigation and content rendering.

* feat(system-settings): enhance request limits settings with new section and unified component

- Added a new Request Limits section to the system settings sidebar, integrating it with the section registry for improved navigation.
- Replaced SettingsAccordion with SettingsSection in RateLimitSection, SensitiveWordsSection, and SSRFSection for consistency.
- Updated RequestLimitsSettings to utilize the new SettingsPage component for better data handling and rendering.
- Implemented a search schema for request limits to streamline navigation and section management.

* feat(system-settings): integrate content settings sections with unified component and registry

- Added a new Content section to the system settings sidebar, incorporating it into the section registry for improved navigation.
- Replaced SettingsAccordion with SettingsSection in multiple content-related components for consistency.
- Created a section registry to manage content settings dynamically, enhancing the rendering and navigation experience.
- Updated the ContentSettings component to utilize the new section registry and streamline content display.

* feat(system-settings): enhance integrations settings with unified section registry and components

- Introduced a new section registry for integrations settings, consolidating various settings components for better organization and navigation.
- Replaced SettingsAccordion with SettingsSection in multiple integration-related components for consistency.
- Updated IntegrationSettings to utilize the new SettingsPage component, improving data handling and rendering.
- Added a new integrations section to the system settings sidebar, enhancing user experience and accessibility.

* feat(system-settings): unify model settings with new section registry and components

- Introduced a section registry for model settings, consolidating various model-related components for improved organization and navigation.
- Replaced SettingsAccordion with SettingsSection in multiple model settings components for consistency.
- Updated ModelSettings to utilize the new SettingsPage component, enhancing data handling and rendering.
- Added a new Models section to the system settings sidebar, improving user experience and accessibility.

* feat(system-settings): enhance maintenance settings with unified section registry and components

- Introduced a new section registry for maintenance settings, consolidating various maintenance-related components for improved organization and navigation.
- Replaced SettingsAccordion with SettingsSection in multiple maintenance components for consistency.
- Updated MaintenanceSettings to utilize the new section registry, enhancing data handling and rendering.
- Added a new Maintenance section to the system settings sidebar, improving user experience and accessibility.

* feat(system-settings): update section titles for improved clarity and consistency

- Renamed various section titles across content, integrations, maintenance, models, and request limits to enhance clarity and better reflect their functionalities.
- Adjusted titles such as 'Dashboard' to 'Data Dashboard', 'API Info' to 'API Addresses', and 'Update Checker' to 'System maintenance' for improved user understanding.
- Ensured consistency in naming conventions across all settings sections to streamline user experience and navigation.

* feat(nav-group): enhance collapsible menu behavior and URL matching logic

- Added controlled state management for collapsible menu items to automatically expand based on active sub-item paths.
- Updated the URL matching logic in checkIsActive to improve handling of query parameters and ensure accurate navigation state detection.
- Refactored the collapsible component to utilize the new state management, enhancing user experience in the sidebar navigation.

* feat(system-settings): update system settings navigation and redirect logic

- Changed the link in the profile dropdown to point directly to the general section of system settings with a search parameter for section identification.
- Implemented a redirect in the general settings route to ensure users are directed to the default section if no section parameter is provided, enhancing navigation consistency.

* feat(system-settings): unify route configuration for settings sections

- Refactored route configuration for various system settings sections (auth, content, general, integrations, maintenance, models, request limits) to utilize a new `createSettingsRouteConfig` function.
- This change consolidates the repetitive logic of creating search schemas and handling redirects, improving code maintainability and readability.
- Enhanced navigation by ensuring default sections are loaded when no section parameter is provided.

* feat(url-utils): enhance URL handling and matching logic

- Introduced a new utility function `urlToString` to convert various URL formats (string and object) into a standardized string format.
- Updated the `checkIsActive` function to utilize `urlToString`, improving the accuracy of URL matching and handling of query parameters.
- Refactored URL comparison logic to ensure consistent behavior across different URL types, enhancing navigation state detection.

* feat(system-settings): validate DataExportDefaultTime for improved data handling

- Introduced a new function `validateDataExportDefaultTime` to ensure the `DataExportDefaultTime` value is either 'week', 'hour', or 'day', defaulting to 'hour' for unexpected values.
- Updated the `DataExportDefaultTime` assignment in the settings section to utilize this validation function, enhancing data integrity and user experience.

* perf(system-settings): Improve the i18n of system settings content

- Changed button labels in various sections to use consistent capitalization and translation functions, enhancing user experience.
- Updated validation messages in schemas to utilize translation functions for improved internationalization support.
- Ensured all user-facing strings are properly translated, improving accessibility for non-English users.

* fix(system-settings): update ApiInfoFormValues type inference for improved schema validation

- Changed the type inference for ApiInfoFormValues to utilize ReturnType of createApiInfoSchema, ensuring accurate type representation and enhancing type safety in the API info section.

* fix(chat-settings): improve validation logic for chat settings schema

- Updated the validation logic to ensure that null values are correctly handled and that only objects are accepted as valid items in the chat settings schema.
- Simplified error handling by removing the error message from the catch block, providing a consistent user-facing message for invalid JSON strings.

* fix(system-settings): enhance validation error handling in uptime-kuma schema

- Updated the validation logic for category name, URL, and slug fields to use an object format for error messages, improving clarity and consistency in user feedback.
- Ensured that all validation messages are properly structured to enhance internationalization support.

* fix(i18n): add translations for Uptime Kuma group management

- Added English, French, Japanese, Russian, Vietnamese, and Chinese translations for "Add Uptime Kuma Group" and "Edit Uptime Kuma Group" to enhance internationalization support.
- Included validation messages for category name and slug fields across multiple languages to improve user feedback and accessibility.

* fix(system-settings): improve validation error message structure for SystemName

- Updated the validation logic for the SystemName field to use an object format for error messages, enhancing clarity and consistency in user feedback.
- This change aligns with recent improvements in internationalization support across the system settings schemas.

* perf(i18n): add new validation error message translations

- Added translations for the new validation error message "Invalid JSON format or values out of allowed range" in English, French, Japanese, Russian, Vietnamese, and Chinese.
- This update enhances internationalization support by ensuring users receive clear feedback across multiple languages.

* fix(i18n): update Japanese translation for payment method configuration message

- Corrected the Japanese translation for the message regarding payment methods configuration to use the term "メソッド" instead of "方法" for improved accuracy and consistency in user feedback.
- This change enhances the clarity of the message for Japanese-speaking users.

* fix(i18n): remove unnecessary loading messages from French translations

- Removed the French translations for "Loading settings...", "Loading maintenance settings...", and "Loading content settings..." to streamline the localization file.
- This change improves the clarity and relevance of the translations provided to users.

* fix(i18n): add translations for Uptime Kuma group management in multiple languages

- Added French, Japanese, Russian, Vietnamese, and Chinese translations for "Add Uptime Kuma Group" and "Edit Uptime Kuma Group" to enhance internationalization support.
- This update improves user experience by providing clear and consistent messaging across different languages.

* fix(validation): enhance pricing schema error messages and add translations

- Updated the pricing schema to include localized error messages for validation, ensuring users receive clear feedback when input values are invalid.
- Added new translations for "Exchange rate is required" and "Exchange rate must be greater than 0" in English, French, Japanese, and Chinese to improve internationalization support.
- This change enhances user experience by providing accurate and contextually relevant messages across multiple languages.

* fix: codex Unsupported parameter: max_output_tokens

* fix(model-mapping-editor): simplify JSON parsing logic in useEffect

* fix: jimeng i2v support multi image by metadata

* refactor(models): restructure models section handling and improve UI components

- Replaced tab-based navigation with section-based navigation for better clarity and organization.
- Introduced a new section registry to manage model sections, including 'metadata' and 'deployments'.
- Updated the ModelsContent component to reflect the new section structure and added a Create Deployment button.
- Removed the ModelsTabs component as it was no longer needed.
- Enhanced internationalization support by adding new translations for section descriptions and management tasks.
- Adjusted sidebar configuration to accommodate the new section structure.

* fix: update warning threshold label from '5$' to '2$'

* fix: video content api Priority use url field

* fix: update abortWithOpenAiMessage function to use types.ErrorCode

* feat(deployment): introduce CreateDeploymentDrawer component and update dialog references

- Replaced the CreateDeploymentDialog with a new CreateDeploymentDrawer component for improved user experience.
- Added comprehensive form handling for deployment creation, including validation and price estimation features.
- Updated internationalization files to include new translations for UI elements and descriptions related to deployment configuration.
- Enhanced the ModelsContent component to integrate the new drawer for creating deployments.

* perf(i18n): enhance internationalization for models table and columns

- Updated labels and titles in the ModelsTable and useModelsColumns components to utilize translation functions for improved localization.
- Changed static text for vendor and sync status to dynamic translations, enhancing user experience for non-English speakers.
- Updated empty state messages in the ModelsTable to support internationalization, ensuring clarity for all users.

* fix: fix email send

* fix: issue where consecutive calls to multiple tools in gemini all returned an index of 0

* fix: replace Alibaba's Claude-compatible interface with the new interface

* fix: Only models with the "qwen" designation can use the Claude-compatible interface; others require conversion.

* feat: log shows request conversion

* feat: optimized display

* feat: optimized display

* feat: optimized display

* fix: codex rm Temperature

* Revert "fix: video content api Priority use url field"

* feat: requestId time string use UTC

* feat(qwen): support qwen image sync image model config

* feat: sync old ui

* feat: more ui sync

* feat: replace theme

* fix build

* refactor(web): revert theme colors and variables in CSS

Updated color variables for light and dark themes to improve consistency and visual appeal.

* feat(deployment): enhance deployment access guard and model deployment settings

- Introduced loading phase management in the DeploymentAccessGuard component to provide better user feedback during connection checks.
- Updated the ModelsContent component to prefetch the deployments list while checking connection status, improving data readiness.
- Implemented a caching mechanism for connection status in useModelDeploymentSettings to optimize performance and reduce unnecessary API calls.
- Enhanced loading states and error handling for improved user experience during deployment settings retrieval and connection testing.

* feat(i18n): add new translations for connection and loading states across multiple languages

- Introduced translations for "Checking connection" and "Loading configuration" in English, French, Japanese, Russian, Vietnamese, and Chinese.
- This update enhances the internationalization support, providing clearer user feedback during connection checks and loading phases.

* refactor(pagination): adjust layout and styling for pagination component

- Updated the pagination component to improve layout by removing unnecessary width constraints and enhancing responsiveness.
- Increased minimum width for pagination text to ensure better visibility and alignment across different screen sizes.

* feat(i18n): implement translations for various UI elements across multiple components

- Updated several components to utilize the translation function for titles and placeholders, enhancing internationalization support.
- Added new translation entries for "Filter by name or key..." and "Log Type" in English, French, Japanese, Russian, Vietnamese, and Chinese.
- This update improves user experience by providing localized text in the ChannelsTable, SummaryCards, ApiKeysTable, RedemptionsTable, UsageLogsTable, and UsersTable components.

* feat(i18n): integrate translation support in SummaryCards component

- Added the useTranslation hook to the SummaryCards component to enhance internationalization.
- This update allows for localized text rendering, improving user experience for diverse language speakers.

* feat(dashboard): refactor dashboard structure and introduce section-based navigation

- Removed the tab navigation in favor of a section-based approach, enhancing user experience by providing clearer context for the dashboard content.
- Introduced a new section registry to manage dashboard sections, allowing for easier expansion and maintenance.
- Updated sidebar configuration to reflect the new section structure, ensuring proper navigation links are displayed.
- Added translations for new section titles and descriptions to support internationalization.

* feat(i18n): update time range labels and enhance translation support

- Changed time range labels from shorthand (e.g., '1D') to full text (e.g., '1 Day') for better clarity.
- Updated various components to utilize the translation function for time range labels, improving internationalization.
- Added new translation entries for time ranges in English, French, Japanese, Russian, Vietnamese, and Chinese, enhancing user experience across languages.

* feat(dashboard): enhance type safety and improve component structure

- Updated the Dashboard component to use specific types for model data and filters, enhancing type safety.
- Introduced new types for announcements and FAQs, improving clarity and maintainability.
- Refactored LogStatCards and UptimePanel components to utilize AbortController for better data fetching management.
- Optimized the rendering of announcements and FAQs by using unique keys based on item IDs.
- Improved theme management in ModelCharts by caching the ThemeManager import to reduce dynamic imports.

* feat(agents): add comprehensive guidelines for React and Next.js development

- Introduced a new set of best practices and optimization techniques for React and Next.js applications, aimed at enhancing performance and maintainability.
- Included detailed rules covering various aspects such as event handling, API routes, rendering strategies, and state management.
- Added extensive documentation in AGENTS.md and SKILL.md to support developers in adhering to these practices.
- This update serves as a foundational resource for improving code quality and efficiency in React-based projects.

* chore(web): update package.json dependencies

- Removed outdated dependencies including @base-ui/react, @clerk/clerk-react, and others to streamline the project.
- Updated remaining dependencies to their latest versions for improved performance and security.
- This cleanup enhances the overall maintainability of the project.

* feat(usage-logs): implement section-based navigation and enhance log management

- Introduced a section registry for usage logs, allowing for better organization and navigation between different log categories (common, drawing, task).
- Updated the UsageLogsContent component to dynamically render titles and descriptions based on the selected section.
- Refactored UsageLogsTable and UsageLogsPrimaryButtons components to accept the active log category as a prop, improving modularity.
- Enhanced sidebar configuration to support new section navigation, ensuring users can easily access different log types.
- Updated routing to redirect to the default section if none is specified, improving user experience.

* feat(i18n): enhance internationalization across usage logs components

- Integrated the useTranslation hook in various components related to usage logs, including CommonLogsStats, UsageLogsTable, and column helpers.
- Updated labels, titles, and messages to utilize translation functions, improving localization support.
- Added new translation entries for log-related terms in English, French, Japanese, Russian, Vietnamese, and Chinese, enhancing user experience for diverse language speakers.

* feat(datetime-picker): integrate dayjs for date formatting

- Added dayjs as a dependency to the project for improved date handling.
- Updated the DateTimePicker component to use dayjs for formatting dates, enhancing consistency and readability of date displays.

* feat(date-handling): replace date-fns with dayjs for improved date management

- Updated the project to use dayjs instead of date-fns for date formatting and manipulation, enhancing consistency across components.
- Refactored DatePicker, DateTimePicker, and other components to utilize dayjs for date-related functionalities.
- Added a new dayjs configuration file to extend its capabilities with relative time support.
- Updated AGENTS.md to reflect the new technology stack, emphasizing the use of dayjs for date handling.

* refactor(agents): streamline front-end development guidelines and update technology stack

- Revised AGENTS.md to condense front-end development standards and best practices, making it more accessible for developers and AI assistants.
- Updated the technology stack section to reflect current dependencies, emphasizing the use of Bun, React 19, TypeScript, and other key libraries.
- Enhanced the document structure with a new table format for better readability and navigation, including a comprehensive table of contents for quick access to sections.

* feat(i18n): enhance date picker and datetime picker localization support

- Integrated internationalization support in DatePicker and DateTimePicker components by adding locale handling for multiple languages (English, French, Japanese, Russian, Vietnamese, Chinese).
- Updated the calendar component to accept a locale prop, ensuring proper localization of month and weekday labels.
- Improved user experience by allowing date selection to adapt based on the user's language preference.

* feat(layout): add SectionPageLayout component for structured page layouts

- Introduced a new SectionPageLayout component to facilitate structured layouts for pages with sections, enhancing the organization of content.
- Added subcomponents for Title, Description, Actions, and Content to improve clarity and maintainability of page structures.
- Updated AGENTS.md to include guidelines on avoiding unnecessary destructuring of props for better code readability.

* feat(layout): refactor components to use SectionPageLayout for improved structure

- Replaced AppHeader and Main components with SectionPageLayout across multiple features including Channels, Dashboard, ApiKeys, Models, Redemption Codes, Usage Logs, Users, and Wallet.
- Enhanced page organization by utilizing SectionPageLayout's Title, Description, Actions, and Content subcomponents, improving clarity and maintainability.
- This update standardizes the layout structure across the application, facilitating a more cohesive user experience.

* feat(usage-logs): enhance URL state management and redirection logic

- Added useEffect to synchronize column filters with URL search changes, preventing infinite loops caused by inline references.
- Improved redirection logic in usage logs to clear 'type' from the URL when the section is not 'common', enhancing user experience and URL cleanliness.

* fix(usage-logs): disable global filter and update DataTableToolbar props

- Disabled the global filter in the UsageLogsTable component to streamline the user interface.
- Updated the DataTableToolbar component to accept a null customSearch prop, enhancing flexibility in toolbar configuration.

* feat(routes): implement section-based routing for system settings and dashboard

- Introduced section-based routing for system settings and dashboard features, enhancing navigation and organization.
- Updated route definitions to include dynamic sections, allowing for more granular access to settings and dashboard components.
- Refactored existing routes to redirect to default sections when no specific section is provided, improving user experience.
- Added new section routes for models, usage logs, and system settings, ensuring consistency across the application.
- Removed deprecated routes to streamline the routing structure and improve maintainability.

* refactor(usage-logs): update column helper functions to require config parameter

- Modified createFailReasonColumn and createProgressColumn functions to require a config parameter instead of allowing it to be optional.
- Simplified destructuring of config to enhance clarity and ensure necessary properties are always provided, improving code reliability.

* refactor(usage-logs): improve section ID validation and routing logic

- Introduced a type guard function, isUsageLogsSectionId, to validate section IDs, enhancing type safety and reducing the need for casting.
- Updated UsageLogsContent to utilize the new validation function for determining the active category, improving clarity and reliability.
- Refactored routing logic to use isUsageLogsSectionId for section validation, ensuring proper redirection to the default section when necessary.

* refactor(calendar): update locale documentation for i18n support

- Revised the locale prop documentation in the Calendar component to specify the use of react-day-picker for internationalization, clarifying the expected locale setup for users.

* chore(i18n): remove redundant user information description from locale files

- Removed the user information description from English, French, Japanese, Russian, Vietnamese, and Chinese locale files to streamline translations and improve clarity.

* chore(i18n): streamline locale files by removing redundant entries

- Removed unnecessary entries from English, French, Japanese, Russian, Vietnamese, and Chinese locale files to enhance clarity and reduce clutter.
- Adjusted translations for consistency and improved user experience across multiple languages.

* chore(sidebar): remove deprecated usage logs route from sidebar config

- Eliminated the '/usage-logs' entry from the sidebar configuration to streamline navigation and improve clarity in the sidebar structure.

* refactor(redemption-codes): enhance internationalization support and improve UI consistency

- Updated various components to utilize translation functions for user-facing strings, ensuring a consistent experience across different languages.
- Added meta labels for table columns to improve accessibility and clarity.
- Revised confirmation and action texts in dialogs and tooltips to leverage translation, enhancing user experience.
- Updated locale files to include new translations for improved clarity and consistency.

* feat(masked-value-display): add MaskedValueDisplay component for sensitive data handling

- Introduced a new MaskedValueDisplay component to display masked values with a popover for full value visibility and a copy button for easy access.
- Updated api-keys-columns and redemptions-columns to utilize the new component, enhancing code reusability and UI consistency.
- Revised translation keys in locale files to remove colons for improved clarity.

* refactor(url-utils): simplify query parameter matching logic in checkIsActive function

- Updated the checkIsActive function to streamline the logic for matching URLs with and without query parameters.
- Removed unnecessary checks for query parameters when matching base paths, improving clarity and maintainability of the code.

* fix(channels-table): update group filter label to use translation function

- Replaced hardcoded 'All Groups' label with a translation function call to enhance internationalization support in the ChannelsTable component.

* chore(api-keys): remove deprecated API key action messages and related exports

- Deleted the api-key-actions.ts file, which contained action messages for enabling, disabling, and deleting API keys.
- Updated index.ts to remove the export of getApiKeyActionMessage, streamlining the codebase by eliminating unused functionality.

* refactor(i18n): enhance internationalization support across various components

- Updated multiple components to utilize translation functions for user-facing strings, ensuring a consistent experience across different languages.
- Revised constants and labels in the channels and redemption codes features to use i18n keys, improving maintainability and clarity.
- Ensured that success and error messages leverage translation functions, enhancing user experience and accessibility.
- Streamlined the handling of i18n keys in the constants files for better organization and clarity.

* refactor(i18n): enhance translation support across various components

- Updated multiple components to utilize translation functions for user-facing strings, ensuring a consistent experience across different languages.
- Revised pagination and status labels to use i18n keys, improving maintainability and clarity.
- Enhanced response time formatting to support internationalization, allowing for localized display of time values.
- Updated locale files to include new translations for improved clarity and consistency.

* docs(AGENTS): add type checking requirement for TypeScript changes

- Included a new guideline stating that type checks must be executed after modifying TypeScript or TSX code, ensuring no type errors are left unresolved.
- Updated the document to reflect this addition in the relevant section for better clarity on coding standards.

* feat(combobox-input): add ComboboxInput component for enhanced token selection

- Introduced a new ComboboxInput component to facilitate token name selection with search and filtering capabilities.
- Integrated the ComboboxInput into the UsageLogsFilterDialog for improved user experience when filtering by token name.
- Updated locale files to include new translations for user-facing strings related to token filtering.

* feat(combobox): integrate translation support for custom value prompt

- Added translation functionality to the Combobox component, replacing hardcoded text with a translatable string for the custom value prompt.
- Utilized the useTranslation hook from react-i18next to enhance internationalization support, ensuring a consistent user experience across different languages.

* refactor(i18n): improve Chinese translations for consistency and clarity

- Adjusted spacing in various Chinese translations to enhance readability and maintain consistency across the locale file.
- Updated multiple user-facing strings to ensure proper formatting and alignment with localization standards.

* feat(calendar): add CalendarDropdown component for enhanced dropdown functionality

- Introduced a new CalendarDropdown component to improve user interaction with dropdown selections in the calendar.
- Implemented state management for dropdown visibility and selection handling, enhancing the overall user experience.
- Updated styling for dropdown elements to ensure consistency and better alignment with the UI design.

* fix(balance-query-dialog): handle null currentRow and improve usage query logic

- Updated the BalanceQueryDialog component to safely access currentRow properties using optional chaining.
- Added a check to ensure currentRow is not null before proceeding with usage queries, preventing potential runtime errors.
- Refactored the handleQueryCodexUsage function to use a local variable for currentRow, enhancing code clarity.

* feat(i18n): add new translations for batch creation and channel updates

- Added new translation strings for batch creation instructions across multiple languages, enhancing user guidance.
- Included translations for the "Update Channel" prompt to improve clarity in channel configuration settings.
- Ensured consistency in terminology across locale files for better user experience.

* feat(channel-mutate-drawer): improve API key input handling and update translations

- Refactored the API key input logic in the ChannelMutateDrawer component to enhance readability and maintainability.
- Added new placeholder translations for batch creation and existing key prompts in multiple languages, improving user guidance.
- Ensured consistency in translation strings across locale files for better user experience.

* feat(fetch-models-dialog): implement sorting for model categories

- Added a new function to sort model categories alphabetically, placing 'Other' at the end for easier navigation.
- Updated the rendering logic in the FetchModelsDialog component to utilize the new sorting function for both new and existing models, enhancing user experience.

* refactor(wallet-stats-card): standardize props usage and improve layout consistency

Standardizes props usage and improves layout consistency in wallet stats card

Refactors the wallet stats card component to:
- Use props directly instead of destructuring for consistency
- Add min-w-0 to prevent content overflow
- Adjust text sizing with break-all for proper wrapping
- Implement responsive font sizes (3xl on mobile, 4xl on larger screens)
- Improve leading and tracking for better readability

Refactor wallet stats card for consistency and layout

Standardizes props usage and improves layout consistency in wallet stats card

- Uses props directly instead of destructuring for consistency
- Adds min-w-0 to prevent content overflow
- Adjusts text sizing with break-all for proper wrapping
- Implements responsive font sizes (3xl on mobile, 4xl on larger screens)
- Improves leading and tracking for better readability

* feat(web): add subscription management and admin settings UI

* feat(web): add subscription management and admin settings UI

- Add subscription management module (plans, pricing, toggle status, and related dialogs/tables with Stripe/Creem integration notes)
- Add channel affinity (rules and cache stats), Waffo integration, performance, and Grok model sections to system settings, with extended types and section registry
- Add status code mapping validation/risk warnings, upstream update hooks, and utilities for channels; add available models and sidebar module cards to user profile
- Add chat2link route and useMinimumLoadingTime, useTableCompactMode shared hooks

Made-with: Cursor

* fix: remove duplicate GenerateOAuthCode and add missing TaskBulkUpdate

- remove duplicate GenerateOAuthCode from github.go since oauth.go already has the generic version.
- add model.TaskBulkUpdate for bulk update by upstream task_id strings, fixing task_video.go build failure.

* feat(router): add chat2link and subscriptions routes

- register /chat2link page route under authenticated layout.
- register /subscriptions/ page route under authenticated layout.
- update auto-generated routeTree type definitions and route mappings.

* feat(docker): add development environment setup with Docker Compose

- Introduced docker-compose.dev.yml for local development, including services for new-api, Redis, and PostgreSQL.
- Created Dockerfile.dev for backend-only builds, optimizing the development workflow.
- Updated makefile to include new commands for starting backend services and frontend development.

* feat(web): complete i18n coverage for setup wizard and add language switcher

- wrap all hardcoded English strings in setup-wizard, database-step, usage-mode-step, and complete-step with t() calls, covering step titles, descriptions, form validation messages, and fallback strings.
- add LanguageSwitcher component to the top-right corner of the setup page so users can switch language during initial setup.
- register 25 dynamic i18n keys in static-keys.ts and provide full translations for zh/en/ja/fr/ru/vi.

* feat(i18n): internationalize default version text in workspace-switcher

- remove hardcoded 'Unknown version' default, use t('Unknown version') for i18n fallback
- add "Unknown version" translation entries across all 6 locale files (zh/en/fr/ru/ja/vi)

* feat(i18n): add full i18n coverage for channel-affinity settings page

- replace Chinese t() keys with English keys across three channel-affinity components to align with new frontend i18n conventions.
- add 51 translation entries to all 6 locale files (en/zh/ja/fr/ru/vi) covering main page, rule editor, and cache stats dialog.
- register section-registry dynamic keys in static-keys.ts.

* feat(i18n): add full i18n coverage for Waffo payment settings page

- replace Chinese i18n keys with English keys in waffo-settings-section.tsx for consistency.
- wrap previously hardcoded labels (Pay Method Type / Pay Method Name) with t().
- add 26 Waffo-related translation entries across all 6 locale files (en/zh/fr/ru/ja/vi).

* feat(i18n): add missing translations for global model settings page

- add all 6 locale translations for 3 missing t() keys in global-settings-card.
- register dynamically used 'Grok' key in static-keys.ts for i18n scanner coverage.

* feat(i18n): add full i18n coverage for Grok model settings page

- add translations in all 6 locales (en/zh/fr/ja/ru/vi) for grok-settings-card t() calls.
- cover violation fee toggle, amount input, and official docs link labels.
- include section-registry descriptionKey translation entries.

* feat(i18n): add full i18n coverage for performance settings page

- migrate all t() keys from Chinese to English to align with project conventions.
- add translations for all 6 locales (en/zh/ja/fr/ru/vi) covering disk cache,
  system monitoring, log management, and stats dashboard sections.
- remove 71 obsolete Chinese-keyed entries from every locale file.

* fix(i18n): add 116 missing English translation keys across all locales

- scan all t() calls to identify English keys used in code but absent from locale files.
- add translations for zh/en/fr/ja/ru/vi, keeping key sets and sort order consistent.
- covers system-settings, channels, models, auth, wallet and other modules.

* fix(i18n): add missing translations for log cleanup quick-select and confirm dialog

- wrap quick-select button labels (24 hours ago / 7 days ago / 30 days ago) with t().
- replace hardcoded English strings in purge confirm dialog with t() calls and date interpolation.
- add 5 new translation keys across all 6 locale files (zh/en/fr/ja/ru/vi).

* refactor(web): unify all time display with dayjs formatting

- replace all toLocaleString/toLocaleDateString/toLocaleTimeString and manual padStart concatenation with dayjs.format().
- standardize output: datetime as YYYY-MM-DD HH:mm:ss, date as YYYY-MM-DD, time as HH:mm:ss.
- add formatDateTimeStr, formatDateStr, formatTimeStr dayjs-based helpers in lib/format.ts.
- update 12 files across core utils and feature components.

* refactor(web): replace native datetime-local input with DateTimePicker in announcements

- swap browser-native datetime-local for the project's DateTimePicker component to match the UI used in log cleanup and other pages.
- convert between Date objects and ISO strings to bridge the form's string-based schema.

* refactor(web): replace native HTML elements with design system components

- replace ~35 native <button> with <Button> across pricing, profile, channels modules
- replace native <input>/<textarea>/<label> with <Input>/<Textarea>/<Label> for consistent form styling
- replace native <table> with <Table> components, <details>/<summary> with <Collapsible>
- replace decorative <hr> with <Separator> to ensure global UI consistency

* refactor(web): enhance profile components with design system consistency

- update ProfileSecurityCard to use buttons for security actions, improving accessibility and styling.
- modify AccountBindingsTab layout to a grid for better responsiveness and visual alignment.
- refactor NotificationTab to utilize icons for notification methods, enhancing user experience and clarity.

* fix(i18n): complete i18n coverage for profile page components

- wrap passkey card status badges (enabled/disabled, backup state) and last-used text with t()
- fix hardcoded button labels in security dialogs (change password, access token, delete account)
- internationalize all 2FA dialog strings (setup, disable, backup codes)
- fix email bind dialog description and button state text missing i18n
- wrap remaining hardcoded strings in notification tab and checkin calendar
- add all missing translation entries to zh.json and en.json

* fix(i18n): enhance error messages with translations for deployment access and settings

- wrap connection error messages in DeploymentAccessGuard and IoNetDeploymentSettingsSection with t() for internationalization.
- add missing translation key for "io.net model deployment is not enabled or api key missing" in all locale files (en, fr, ja, ru, vi, zh).

* 🧹 chore(web): resolve all ESLint errors and warnings

Align the Vite/React frontend with the current ESLint flat config and
React Compiler–related rules by fixing violations instead of broad
suppression where practical.

- Replace `any` with concrete types (`unknown`, `Record<string, unknown>`,
  domain types) where upstream/API shapes allow
- Fix duplicate imports, unused bindings, `no-console`, and empty blocks
- Address react-hooks issues: reorder declarations, memoize unstable
  callbacks (`useCallback`), extend dependency arrays, and use targeted
  disables only where sync-from-props in `useEffect` is intentional
- Refactor `motion.create` usage in ai-elements shimmer to avoid creating
  components during render (static-components)
- Stabilize TanStack Query/Mutation hook usage (query keys, `mutate` in
  deps) and add narrowly scoped rule disables where the linter conflicts
  with library patterns
- Disable `react-hooks/incompatible-library` in ESLint config for
  TanStack Table / RHF false positives
- Add file-level `react-refresh/only-export-components` disables for
  registry/provider/column modules that intentionally mix exports

`bun lint` completes with 0 errors and 0 warnings.

*  feat(web): add subscription management to sidebar and align drawer with project conventions

- Register "Subscription Management" nav item in the admin sidebar group
  with CreditCard icon pointing to /subscriptions
- Add subscription module to sidebar config defaults and URL mapping so it
  integrates with the admin sidebar modules toggle in system settings
- Add subscription entry to sidebar-modules-section moduleMeta for the
  maintenance settings UI
- Refactor SubscriptionsMutateDrawer to follow the same patterns used by
  users, redemption-codes, and other mutate drawers:
  - Use shadcn Form/FormField/FormItem/FormControl/FormLabel/FormMessage
    instead of raw register() + Label + manual error display
  - Move SheetFooter outside the form with form attribute association
  - Use SheetClose for the cancel button
  - Reset form state on drawer close
  - Align SheetContent width (sm:max-w-[600px]) and spacing conventions

*  feat(web): overhaul UI/UX with Vercel Geist design alignment

Refactor the entire frontend UI/UX to align with Vercel/OpenAI design
principles, covering layout, animations, skeleton loading, and overall
visual polish.

Motion & Page Transitions:
- Add centralized motion system (lib/motion.ts) with Vercel-style
  transition presets, stagger variants for tables, cards, and sidebars
- Implement AnimatedOutlet for route-level page enter animations
  using TanStack Router pathname keying
- Add PageTransition, StaggerContainer, StaggerItem, CardStagger,
  and TableStagger wrapper components for progressive reveal effects

Skeleton Loading — Vercel Geist Style:
- Replace shadcn default `animate-pulse` with Geist-style shimmer
  sweep animation (linear-gradient + background-position keyframes)
- Add `--skeleton-base` / `--skeleton-highlight` CSS variables tuned
  for both light and dark themes with neutral oklch tones
- Override auto-skeleton-react inline styles via CSS to unify all
  skeleton elements under the same shimmer effect
- Update TableSkeleton with varied column widths for a natural feel
- Add ContentSkeleton and QuerySkeleton wrappers for auto-skeleton
  integration with React Query error/loading states
- Respect prefers-reduced-motion: disable shimmer for accessibility

Layout & Sidebar:
- Upgrade sidebar expand/collapse transitions to cubic-bezier easing
- Add hover micro-interactions (background-color, color, transform)
  to sidebar menu buttons with smooth 150ms transitions
- Fix oklch color compatibility in sidebar outline variant
- Integrate AnimatedOutlet into AuthenticatedLayout for unified
  route-level animations

Theme & CSS:
- Streamline theme.css with cleaner oklch color definitions
- Add CSS table row stagger-in animations with nth-child delays
- Fix hover-scrollbar color bug (hsl → color-mix for oklch compat)
- Add content-auto utility for long list rendering optimization

Cleanup:
- Remove deprecated skeleton-wrapper.tsx
- Remove unused imports and dead code across components
- Add empty-state, error-state, and loading-state utility components

* 🐛 fix(docker): track bun.lock to fix Docker build failure

Remove `web/bun.lock` from `.gitignore` so the lock file is committed
to version control. The Dockerfile `COPY web/bun.lock .` instruction
requires this file to be present in the build context, and ignoring it
caused the build to fail with a "not found" error.

* ⬆️ chore(web): upgrade dependencies and fix all type/lint errors

Upgrade all frontend dependencies to latest stable versions:
- lucide-react 0.562 → 1.7 (major: brand icons removed)
- shiki 3.x → 4.x, eslint 9.x → 10.x, knip 5.x → 6.x
- @rsbuild/core 1.3 → 1.7, @types/node 24 → 25
- tailwindcss/postcss 4.1 → 4.2, motion 12.25 → 12.38
- @tanstack/react-query 5.90 → 5.95, zod 4.3.5 → 4.3.6
- react 19.2.3 → 19.2.4, axios 1.13.2 → 1.13.6
- prettier 3.7 → 3.8, typescript-eslint 8.52 → 8.57
- Add missing optional deps: @xyflow/react, embla-carousel-react

Resolve all TypeScript compilation errors introduced by upgrades:
- Replace lucide-react brand icons (Github) with react-icons/si
- Fix react-hook-form Control/Resolver generics for zod v4
- Fix Record<string, unknown> type constraints across API utils
- Fix axios interceptor return types in lib/api.ts
- Add type assertions for useSettings/useStatus hook returns
- Resolve Badge variant, spread type, and route path mismatches

Resolve all ESLint 10 errors:
- preserve-caught-error: attach cause to re-thrown errors
- no-useless-assignment: refactor redundant variable assignments
- prefer-as-const: use `as const` over literal type assertions
- no-unused-vars: prefix type-only schemas with underscore

Update tsconfig lib from ES2020 to ES2022 for Error.cause support.

* 🐛 fix(web): stop pricing model row from centering its content

Wrapping the row in shadcn <Button variant='ghost'> inherits
`justify-center`, and the inner flex container had no width, so
`justify-between` collapsed and the row appeared centered.

* feat: add Waffo payment integration and related UI components

- Introduced Waffo payment method with support for custom icons and settings.
- Updated payment settings section to include Waffo settings.
- Added Waffo payment request handling in the wallet API.
- Enhanced wallet recharge form to support Waffo payment methods.
- Implemented hooks for Waffo payment processing.
- Updated localization files for new Waffo-related strings.
- Added new payment type and icon for Waffo in constants and UI components.
- Refactored topup info handling to include Waffo payment methods and configurations.

* feat(profile): add admin-only upstream model update notification setting

* fix(web): make sidebar module user settings actually take effect

Previously, saving sidebar module preferences in profile had no effect
because the client ignored user-level sidebar_modules entirely. This
fix wires user config into useSidebarConfig so the sidebar updates
immediately without a page refresh.

Changes:
- Add UserPermissions type with sidebar_settings/sidebar_modules fields
- Refactor useSidebarConfig to merge admin × user config with AND logic
- Sync sidebar_modules to auth store on save for immediate UI updates
- Conditionally render SidebarModulesCard based on user permissions
- Treat null/empty user config as "do not narrow" for legacy users

* feat(web): add custom OAuth provider CRUD and login button support

Migrate custom OAuth from v1 to v2:
- Admin CRUD UI with provider table, form dialog, preset templates, and OIDC discovery
- Login page renders dynamic buttons for custom OAuth providers
- Fix account bindings display showing "Not bound" text when already bound

* feat(web): add ServerAddress, SMTPForceAuthLogin, CreateCacheRatio and group special usable settings

Migrate missing v1 system settings to v2:
- ServerAddress input in General > System Information
- SMTPForceAuthLogin toggle in Integrations > Email
- CreateCacheRatio JSON editor in Models > Ratio
- Group special usable group rules editor in Models > Ratio

* feat(web): wire user subscriptions dialog to users table row actions

The UserSubscriptionsDialog component already existed but had no entry point
in the users table dropdown menu. Add "Manage Subscriptions" menu item.

* chore(web): update i18n translations for new settings and custom OAuth

* 💎 refactor(web): redesign pricing page with flat, typography-driven layout

* 🌐 chore(i18n): complete missing translations and normalize project config

- Add 425+ missing translations across fr, ja, ru, zh, vi locales
  for subscription management, sidebar navigation, Grok settings,
  upstream model updates, pricing page, and other UI components
- Add 37 missing i18n keys used in t() calls but absent from locale
  files (pricing filters, display options, audio/cache labels, etc.)
- Fix stale tech stack info in CLAUDE.md, AGENTS.md, and project.mdc:
  React 18 → 19, Vite → Rsbuild, Semi Design → Radix UI + Tailwind
- Fix i18n key format description: "Chinese source strings" → English
- Deduplicate .cursor/rules/project.mdc to avoid triple-loading the
  same rules already present in root CLAUDE.md and AGENTS.md
- Add i18n-translate Cursor skill for repeatable translation workflow

* 🎨 refactor(web): redesign dashboard with flat, typography-driven layout

Replace Card-based dashboard components with a flat, border-driven design
system consistent with the pricing page, following the ui-style.mdc conventions.

Overview section:
- StatCard: replace Card wrapper with flat flex layout, monospace tabular
  values, uppercase tracking-wider labels, layered opacity hierarchy
- PanelWrapper: replace Card/CardHeader/CardContent with rounded-lg border
  container and border-b header
- SummaryCards: merge three stat cards into a single bordered container
  with divide-x separators; decouple border from stagger animation to
  prevent border deformation during entrance transitions
- ApiInfoPanel/Item: full-width list rows with border-b separators,
  monospace route names, layered opacity for URLs and descriptions
- AnnouncementsPanel: native button rows with hover:bg-muted/40, i18n for
  "Click for details" hint
- FAQPanel: lighter border-border/60 accordion dividers, muted answer text
- UptimePanel: uppercase tracking-wider group headers with bg-muted/30
  background, monospace uptime percentages, fine-grained border opacity

Models section:
- LogStatCards: replace Card with rounded-lg border + divide-x grid,
  fix react-hooks/exhaustive-deps by destructuring props before useEffect
- ModelCharts: replace Card+Tabs with bordered container + custom
  segmented control matching ui-style spec
- Suspense fallbacks: match new flat skeleton layout with accurate
  column structure

Animation:
- Wrap models section in FadeIn with staggered delay
- Keep CardStagger for overview panel grid (each panel has own border)

Other:
- Add ui-style.mdc cursor rule documenting the design language
- Disable react-refresh/only-export-components for src/routes/** in
  eslint config (TanStack Router route files always export Route objects)
- Fix zh.json: "Token-based" translation "基于令牌的" → "按量计费"

*  refactor(web): adopt flat dot-and-text design for all status badges

Replace the bordered/colored-background StatusBadge and Badge components
across the entire frontend with a minimal flat design: a small colored
dot followed by colored text, eliminating visual noise from heavy
borders, backgrounds, and rounded pill shapes.

Key changes:

- Redesign StatusBadge to use dot + text instead of bordered pill style,
  removing cva-based background/border variants in favor of exported
  dotColorMap and textColorMap lookup tables
- Add children prop support to StatusBadge for flexible content rendering
  alongside the existing label prop
- Migrate all Badge usages (except pricing page) to StatusBadge with
  appropriate variant mappings (default→info, secondary→neutral,
  outline→neutral, destructive→danger)
- Consolidate adjacent multi-badge groups into single-dot layouts with
  dot separators (·) to reduce visual clutter in:
  - Channel balance columns (used + remaining)
  - Channel type column (type + IO.NET indicator)
  - User invite info column (invited + revenue + inviter)
  - Usage log stats bar (usage + RPM + TPM)
  - Usage log time/FRT column (time + FRT + stream status)
  - Subscription plan counts (active + expired)
  - Channel affinity scope/regex/key-source columns
  - Prefill group card headers (type + ID)
- Export dotColorMap and textColorMap for direct use in custom inline
  layouts that need consistent status colors without the full component

*  refactor(web): redesign public layout and landing page with modern UI

Overhaul the public-facing layout, header, and homepage to deliver a
polished, animation-rich landing experience inspired by contemporary
SaaS design patterns.

Header:
- Replace sticky header with fixed floating navbar that compacts into
  a pill-shaped glass-morphism bar on scroll (backdrop-blur + ring)
- Add smooth 700ms cubic-bezier transitions for scroll-based shrinking
- Build full-screen mobile menu overlay with staggered entry animations
- Remove background color from logo container, show logo image directly

Homepage sections:
- Hero: gradient text title, radial gradient + grid pattern background,
  interactive terminal demo showcasing API request/response
- Terminal demo: auto-cycles through gpt-4o, claude-sonnet-4-20250514,
  gemini-2.5-pro, deepseek-chat with smooth cross-fade transitions,
  clickable model badges, dual theme support (light/dark), fixed height
- Stats: animated counters driven by IntersectionObserver with
  cubic-bezier easing, supports integer and decimal modes
- Features: Bento grid layout with gap-px border technique, each card
  includes contextual visuals (model list, security badge, workflow)
- How It Works: new three-step process section (Configure → Connect →
  Monitor) with connecting gradient line and numbered badges
- CTA: gradient mesh background with scale-in scroll animation
- Footer: streamlined brand column + link columns layout

New components:
- AnimateInView: IntersectionObserver-based scroll animation component
  supporting fade-up, fade-in, scale-in, fade-left, fade-right
- HeroTerminalDemo: themed terminal with model carousel and live
  request/response preview

CSS:
- Add landing page scroll-triggered keyframe animations
- Add terminal demo animations (blink cursor, spinner, pulse indicator)
- Respect prefers-reduced-motion throughout

i18n:
- Add 17 new translation keys across all 6 locales (en/zh/fr/ja/ru/vi)

*  feat(web): align usage logs and channels with legacy UI

Usage logs
- Show Refund (type 6) in detail dialog and hide conversion chain for refunds
- Sync filter dialog state from URL for model, token, group, username, and requestId

Channels
- Support optional stream flag in channel test API, actions, and test dialog
- Show upstream model update badges (+added / -removed) on fetchable channel types
- Add form fields and drawer toggles for upstream model update check and auto-sync
- Persist upstream model update flags in channel settings JSON for fetchable types

i18n
- Add locale strings for upstream model update UI (en, zh, fr, ja, ru, vi)

* 🐛 fix(web): prevent transient vertical scrollbar on tables during animations

Add overflow-y-clip to the shared Table container (data-slot=table-container)
alongside overflow-x-auto. Setting overflow-x to auto implicitly pairs with
overflow-y: auto in browsers, which made the table shell briefly show a
vertical scrollbar during route enter motion (y/blur) and table row stagger.

Remove the redundant descendant selector workaround from the model pricing
GroupPricingSection; behavior is now covered globally by the Table component.

* 🏗️ refactor(web): redesign console layout with fixed header, scrollable content, and pinned footer

Overhaul the authenticated console layout to match the OpenAI dashboard
pattern: header and page title bar stay fixed at the top, only the
content area scrolls, and table pagination is pinned to the bottom.

Layout architecture:
- Lock SidebarInset to full viewport height (h-svh) so all inner
  regions are controlled by flexbox instead of document scroll
- Convert Main from a generic div to a semantic <main> flex container
  with overflow-hidden, removing the legacy `fixed` prop and
  `data-layout` attribute
- Strip scroll-shadow logic and `fixed` prop from Header/AppHeader;
  the header is now naturally fixed as a shrink-0 flex child
- Restructure SectionPageLayout into three flex regions: a shrink-0
  title bar, a flex-1 overflow-auto content area, and a shrink-0
  footer portal target with empty:hidden
- Add min-h-0 to AnimatedOutlet wrappers to prevent flex overflow

Footer portal system:
- Introduce PageFooterProvider / PageFooterPortal (React Context +
  createPortal) so deeply nested table components can render their
  DataTablePagination into the fixed footer without prop drilling
- Migrate all 8 data tables (api-keys, channels, users, models,
  deployments, usage-logs, subscriptions, redemption-codes) to use
  PageFooterPortal for pagination

Page-level fixes:
- Profile: wrap content in a scrollable flex child with proper padding
- SystemSettings: remove overflow-auto from wrapper to avoid nested
  scrollbars (sub-pages manage their own scroll)
- Playground / Error pages: remove obsolete `fixed` props

API keys UX improvement:
- Replace inline key show/hide toggle with a Popover-based reveal,
  removing toggleKeyVisibility and keyVisibility state from the
  provider context

Cleanup:
- Remove dead CSS rule for body:has([data-layout='fixed'])
- Remove unused `fixed` prop from Header, AppHeader, and Main types
- Export PageFooterPortal from layout barrel file

* 💅 refactor(web): polish table UI consistency and add pagination transitions

- Standardize primary action buttons (Create, Add, Search) to size="sm"
  across all pages for visual consistency with channels and models
- Redesign NumericSpinnerInput with minimal inline style: plain text by
  default, hover-revealed +/- buttons, click-to-edit — replacing the
  clunky bordered input with stacked chevron arrows
- Fix vertical scrollbar in channels group column by replacing
  overflow-x-auto with overflow-hidden (redundant with +N collapse)
- Simplify API keys group column: replace colorful StatusBadge pairs
  with clean typography using opacity hierarchy and dot separators
- Move API key copy loading indicator from key text to the copy button
  itself, eliminating layout shift during key resolution
- Reduce page title from text-2xl to text-lg and subtitle to text-sm
  in SectionPageLayout for a more compact header
- Add smooth opacity transition (duration-150) on all 7 server-paginated
  tables during background data fetches (isFetching && !isLoading),
  with pointer-events-none to prevent interaction during loading
- Constrain usage logs Details column width (size: 200, maxSize: 220)

* 🐛 fix(web): restore missing padding on system settings content

The console layout refactor in d2150469 moved padding ownership from
Main onto each route, but SystemSettings was missed — its Outlet
wrapper had no padding, so the content area sat flush against the
sidebar and top nav. Add `px-4 pt-6 pb-4` to match the vertical
rhythm used by SectionPageLayout and the Profile page.

* 📱 refactor(web): standardize mobile responsive layout across all table pages

Unify mobile experience for all data table pages (channels, keys, models,
deployments, usage-logs, users, redemption-codes, subscriptions) with a
consistent layout pattern and cleaner header area.

DataTableToolbar:
- Redesign mobile layout: full-width search input + collapsible filter
  toggle button with active filter count badge
- Filters, additional search, and reset button collapse into an
  expandable section on mobile, keeping the default view compact
- Desktop layout remains unchanged

SectionPageLayout:
- Tighten mobile spacing (padding, gaps) for higher content density
- Scale down title (text-base) and description (text-xs) on mobile
- Shrink action button gaps on small screens

ChannelsPrimaryButtons:
- Move Tag Mode and Sort by ID toggles into the "More" dropdown on
  mobile (via DropdownMenuCheckboxItem), freeing header space
- Desktop toggle switches remain visible outside the dropdown

MobileCardList (shared component):
- Compact list-item layout with title + badge header row and
  side-by-side key fields, replacing individual card components
- Structured (CompactRow) and fallback (FallbackRow) rendering modes
  driven by column meta (mobileTitle, mobileBadge, mobileHidden)

New MobileCardList integration:
- Users table: username as title, status as badge; hide id,
  display_name, invite_info on mobile
- Redemptions table: name as title, status as badge; hide id,
  created_time, expired_time, used_user_id on mobile
- Subscriptions table: plan title as title, enabled as badge; hide id,
  sort_order, reset, payment, total_amount, upgrade_group on mobile

Column meta updates:
- Add mobileTitle/mobileBadge/mobileHidden meta across all 8 table
  column definitions for consistent mobile field prioritization

Minor fixes:
- Hide Subscriptions Stripe/Creem alert on mobile
- Disable card hover animations on mobile via CSS media query

* 🐛 fix(web): sync favicon with custom system logo

Favicon stayed at the hardcoded /logo.png while document.title already
followed system_name, leaving tab icon and site branding out of sync.
Apply the logo as favicon from localStorage cache on startup, refresh
from getStatus(), and re-apply when useSystemConfig finishes preloading.
Extract applyFaviconToDom helper into lib/dom-utils with idempotent guard
to avoid redundant DOM writes.

*  feat(web): add channel affinity rule templates and CreateCacheRatio visual editing

Port missing features from legacy frontend (b8650b9 merge) to the new
React frontend:

- Add Codex CLI and Claude CLI channel affinity rule templates with
  header passthrough presets (pass_headers operations for Originator,
  Session_id, X-Codex-*, X-Stainless-*, Anthropic-*, etc.)
- Introduce "Add Rule" dropdown menu with blank, Codex CLI, and Claude
  CLI template options in the channel affinity settings page
- Add "Fill Templates" button to batch-append both CLI templates with
  duplicate name resolution and confirmation dialog
- Support templateKey prop in RuleEditorDialog to pre-fill form fields
  from selected template, auto-expanding advanced settings when a
  param_override_template is present
- Add CreateCacheRatio support to the model ratio visual editor, edit
  dialog, and form — previously only editable in JSON mode, now fully
  integrated into the visual table column, add/edit dialog fields, and
  save/delete handlers

* 🐛 fix(web): fix content-type detection bugs in About and Home pages

- Fix About page URL detection: replace naive `startsWith('https://')`
  with proper `new URL()` validation to support both http and https, and
  handle untrimmed input that previously caused silent misdetection
- Fix About page HTML detection: remove overly broad `startsWith('<')`
  and `endsWith('>')` checks that could misclassify Markdown or XML
  content; align with LegalDocument's regex-only `isLikelyHtml` approach
- Fix Home page URL detection: same `startsWith('https://')` bug,
  replaced with `new URL()` protocol validation
- Refactor About page to use early-return pattern instead of deeply
  nested ternary expressions for better readability
- Replace About loading spinner with Skeleton placeholder consistent
  with LegalDocument
- Add `prose prose-neutral dark:prose-invert` typography classes to
  About HTML/Markdown rendering for proper dark mode support
- Remove unused `Code` icon import from About page

*  feat(web): port missing features from legacy frontend and complete i18n

Backport and enhance several features from the old frontend (web/old)
that were missing or incomplete in the new React frontend:

- Playground & channel test: parse structured JSON error responses from
  SSE streams and non-streaming API calls, extract error codes, and
  display actionable UI for `model_price_error` (admin settings link)
- User management: replace local quota manipulation with atomic
  server-side quota adjustments (add/subtract/override) via dedicated
  API endpoint, making the quota field read-only in the edit drawer
- Subscriptions: display next quota reset time for active subscriptions
- Dashboard: limit model ranking chart to top 20 models with an "Other"
  bucket, add dimension tooltips with sorted values and totals to model
  call trend and user consumption trend charts
- i18n: add 24 new translation keys across all 6 locales (en, zh, fr,
  ja, ru, vi) for the newly introduced UI elements and messages

* 🎨 feat: add backend-configurable frontend theme switching (default/classic)

Introduce runtime frontend theme switching so administrators can switch
between the new frontend (Radix UI + Tailwind) and the classic frontend
(Semi Design) from the settings page without restarting the server.

Directory restructuring:
- Move new frontend from web/ to web/default/
- Move classic frontend from web/old/ to web/classic/
- One-frontend-per-folder layout for extensibility

Backend (injection pattern):
- Add setting/system_setting/theme.go with GlobalConfig.Register("theme")
  so the DB key "theme.frontend" is handled automatically by
  handleConfigUpdate — no switch-case in updateOptionMap needed
- Use atomic.Value in common.GetTheme()/SetTheme() for lock-free
  concurrent reads on the hot path (static file middleware)
- Add themeAwareFileSystem that delegates to the correct embedded FS
  based on the current theme at request time
- Embed both frontends into the binary via go:embed
- Add controller validation for theme.frontend values
- Expose theme in GET /api/status response

Frontend settings UI:
- New frontend: add "Frontend Theme" select in System Information section
  using Radix UI Select + react-hook-form + Zod validation
- Classic frontend: add "前端主题" select in Personalization section
  using Semi Design Form.Select

Build system:
- Update Dockerfile with multi-stage builds for both frontends
- Update Makefile with separate build targets for each frontend
- Update GitHub Actions release workflow for dual frontend builds

i18n:
- New frontend: add translations for all 6 locales (en/zh/fr/ja/ru/vi)
- Classic frontend: add translations for all 7 locales (en/zh-TW/ja/fr/ru/vi)
- Fix zh "AI Proxy Library" → "AI 代理库"

Documentation:
- Update CLAUDE.md, AGENTS.md, .cursor/rules/project.mdc to reflect
  the new web/default/ and web/classic/ directory structure

*  feat(web): add allow_speed passthrough for Claude channels, fix multi-key index and inference_geo scope

- Add `allow_speed` toggle for Anthropic (type 14) channels to control
  Claude inference speed mode passthrough, with full form schema,
  settings persistence, and UI switch
- Fix `allow_inference_geo` to also apply to Anthropic (type 14) channels,
  not just OpenAI (type 1), matching the backend behavior for Claude data
  residency region control
- Fix multi-key management dialog to display 1-based key indices instead
  of 0-based (#{key.index + 1})
- Fix TypeScript type error in section-registry by adding type assertion
  for theme.frontend enum
- Add i18n translations for all new keys across 6 locales (en, zh, fr,
  ja, ru, vi)

* 🧹 chore: clean up editor configs, consolidate agent skills, and set classic as default theme

- Add .cursor/ to .gitignore and remove tracked editor config files
  (.cursor/rules/, .cursor/skills/) from version control
- Consolidate .agents/skills/vercel-react-best-practices by keeping only
  the compiled AGENTS.md and removing redundant SKILL.md and 57 individual
  rule files under rules/
- Change default frontend theme from "default" to "classic" in both
  common/constants.go init and setting/system_setting/theme.go

* feat: Frontend Tiered Pricing, Waffo Payments, and Rsbuild 2 Upgrade (#24)

* feat(ui): add codex extra limits, key last used, and admin audit surfaces

- codex usage dialog: render `additional_rate_limits` with `RateLimitGroupSection` and typed base/secondary window data.
- api keys table: add "Last Used" column from `accessed_time`.
- usage log details: show top-up audit and manage operator for admins; extend `LogOtherData` audit fields; broaden IP display; warn when legacy records lack audit data.
- billing history: show user id badge for admins; add zh i18n for new strings.

* feat(web): add dynamic pricing breakdown and Waffo Pancake payments

- add billing-expr parsing and DynamicPricingBreakdown; surface tiered_expr in model list/details.
- extend PricingModel with billing_mode, billing_expr, and pricing_version for backend parity.
- add Waffo Pancake integration settings, amount/pay APIs, hook, and recharge flow wiring.
- update payment confirm/recharge UI and Chinese locale strings.

* feat(pricing): add tiered billing editor and tool price settings

- introduce tier-expr and extend billing-expr (time/param conditions, combine/split helpers, editor utilities) for visual tiers and request rules.
- support tiered_expr in model ratio dialog, form, and visual editor with billing_setting fields and default JSON placeholders.
- add TieredPricingEditor and tool price settings UI plus i18n updates.

* chore(web): bump rsbuild to v2 and align build config

- upgrade @rsbuild/core, @rsbuild/plugin-react, and Rspack 2 transitives; bump TanStack Router packages and refresh bun.lock.
- replace deprecated performance.chunkSplit with top-level splitChunks cache groups for react, radix, and tanstack vendors.
- factor dev server proxy into devProxy; set legalComments to none in prod; enable performance.buildCache keyed by VITE_REACT_APP_VERSION.
- TanStack Router plugin: enable autoCodeSplitting only in production for faster dev navigation and HMR.

* fix(i18n): update translations for API keys and Waffo Pancake settings

- Corrected translations for "API Private Key" and "Merchant ID is required" across multiple languages.
- Added new translation for "Configure Waffo Pancake hosted checkout integration for USD-priced top-ups."
- Updated various existing translations to ensure consistency and clarity in user interface text.

* refactor(code-block): simplify code highlighting and improve theme handling

- Updated the highlightCode function to support dual themes in a single call, reducing complexity.
- Removed unnecessary state management for dark theme HTML, streamlining the component.
- Enhanced CSS for Shiki themes to ensure proper token color application in dark mode.

* refactor(wallet): use isWaffoPancakePayment for pancake payment dispatch

- replace the waffo_pancake string literal with the shared helper for consistency with use-payment and PAYMENT_TYPES.
- centralize the value so a constant change does not require hunting for typos in multiple call sites.

* fix(wallet): validate waffo pancake checkout url and safe open

- allow only parseable http/https redirect targets from the backend, rejecting dangerous schemes.
- pass noopener and noreferrer in window.open to reduce reverse tabnabbing.
- show a toast and abort on invalid URLs; add i18n entries across locales.

* fix(wallet): harden payment icon image URLs

- add normalizeHttpIconUrl to allow only http(s) after resolution and reject userinfo in URLs.
- set referrerPolicy, lazy loading, and async decode on the icon <img> to cut referrer leakage.
- fall back to built-in icons on invalid URLs, same as when iconUrl is missing.

* fix(pricing): label param() conditions as body param in dynamic pricing

- non-header request rules map to `param()`, not query strings.
- align with tiered pricing editor by using the existing `Body param` string.

* fix(rsbuild): update legalComments handling in build config

- Rely on Rsbuild's default legalComments setting in all modes to ensure compliance with open-source licensing requirements.
- Clarified comments to explain the implications of omitting legalComments in production.

* fix(i18n): correct billing and codex UI strings in locale files

- restore ~83 en.json values to English (tool pricing, audit text, alipay label, etc.).
- add proper fr/ru/vi/ja strings so those locales no longer copy zh.
- change five locale files only; zh.json unchanged.

* fix(i18n): update locale files for improved translations and sync report

- Added missing translations and corrected existing strings in English, French, Japanese, Russian, Vietnamese, and Chinese locale files.
- Updated the sync report to reflect zero missing translations across multiple locales.
- Enhanced the untranslated count for Japanese locale to ensure completeness.
- Changed the base locale from zh.json to en.json for better alignment.

* chore(agents): add i18n-translate agent skill

- add `.agents/skills/i18n-translate/SKILL.md` documenting locale layout under `web/default` and
  `bun run i18n:sync` usage.
- capture a repeatable maintainer workflow with embedded script examples to find missing keys
  and untranslated values.
- give agents a clear path to complete and verify translations across en, zh, fr, ja, ru, and vi.

* feat(settings): hide frontend theme setting (#25)

* feat(settings): hide frontend theme setting

- add a local hidden feature flag with window.newapiUnlock support.
- hide the frontend theme option by default and reveal it immediately after unlock.

* feat(settings): support click unlock for frontend theme setting

- add a shared hidden click unlock hook for repeated-click gated UI.
- reveal the frontend theme option after triple-clicking the system information title.
- preserve the Doubao API address ten-click unlock behavior and remove global unlock functions.

* feat(sync 59337e9): Sync classic tiered billing, upstream price synchronization, and model management features to web/default (#26)

* feat(skill): add classic-to-default-sync skill for auditing and syncing web/classic changes to web/default

- Introduced a new skill to inspect a given commit's changes in web/classic and synchronize features and fixes to web/default.
- Documented workflow steps for extracting diffs, mapping changes, triaging, implementing, and reporting on the synchronization process.
- Emphasized quality standards and internationalization considerations for new user-visible strings.

* feat(web/default): sync billing and model management features from classic

- add `len` condition variable (total input context length); introduce
  BILLING_PRICING_VARS / BILLING_CONDITION_VARS to separate pricing vars
  from condition-only vars; fix tier condition regex to accept `len`.
- rewrite upstream ratio sync components to support per-model grouped
  rows and new ratio types (create_cache, image, audio, billing_expr).
- add LlmPromptHelper component; update tiered presets to use `len` for
  conditions; add GLM-4.5 Air, Doubao Seed 1.8, Qwen3 Omni Flash, and
  weekend-discount presets.
- add created_at / last_login_at columns to users table; add "Removed
  Models" tab to FetchModelsDialog for mapping source keys not in the
  models list.
- add extractMappingSourceModels helper; update dynamic-pricing-breakdown
  to use system currency settings; add 19 i18n keys across all locales.

*  feat(default): surface tiered billing in usage logs and gate Passkey ops behind 2FA

Continues the classic-to-default sync (commit 1be6cdb) by porting the
remaining audit-log, pricing-hint, and Passkey lifecycle features from
web/classic to web/default using the default frontend's component
patterns (Radix UI, Tailwind, shadcn-style dialogs).

* feat(usage-logs): show tiered_expr breakdown and matched tier in details

  - Extend `LogOtherData` with `billing_mode`, `expr_b64`, and
    `matched_tier` fields populated by the backend for tiered logs.
  - Add `decodeBillingExprB64`, `resolveMatchedTier`, and
    `getTieredBillingSummary` helpers in `usage-logs/lib/format.ts` that
    centralise tiered-billing parsing on top of the canonical
    `parseTiersFromExpr` / `BILLING_PRICING_VARS` from the pricing
    feature, instead of duplicating the classic-frontend renderer.
  - Render `<DynamicPricingBreakdown>` inside the consume-log details
    dialog with the matched tier row highlighted in emerald and tagged
    "Matched"; suppress the legacy claude/audio/image cost rows when a
    tiered expression is in effect.
  - Surface per-tier prices and the matched tier label in log row
    segments and the billing breakdown table.

* feat(pricing): show tier-count, time-based, and request-based hints in model list

  - Add `summarizeTieredExpr` that derives compact dynamic-pricing
    metadata (tier count + presence of time/request conditions) from a
    `tiered_expr` model, computed once per render via `useMemo`, so
    users can tell *what kind* of dynamic pricing applies before
    drilling into the model details.
  - Render the hints alongside the existing "Dynamic Pricing" badge in
    `<ModelRow>`.
  - Extend `<DynamicPricingBreakdown>` with a `matchedTierLabel` prop so
    the same component can be reused from the usage-log details dialog
    to highlight the tier that actually fired.

* feat(profile): require Security Verification for Passkey register/remove

  - Wire `usePasskeyManagement` through `useSecureVerification` and
    `<SecureVerificationDialog>` in `<PasskeyCard>`.
  - Registration prompts for 2FA before issuing the Passkey credential
    (only when 2FA is already enabled — otherwise the browser-level
    Passkey prompt itself acts as proof of presence and we register
    directly).
  - Removal prompts for 2FA or Passkey, whichever the account has
    enabled, with informative toasts when neither method is available
    or the device lacks Passkey support.
  - Scope the dialog method set to the required factor so users cannot
    fall back to a weaker method, and propagate cancellation cleanly.

* refactor: tighten upstream-ratio-sync and fix tier editor narrowing

  - Drop the unused `hasSynced` state and dead `getOrderedRatioTypes` /
    `isSelectableUpstreamValue` imports from `upstream-ratio-sync.tsx`.
  - In the cost estimator, narrow `BILLING_EXTRA_VARS` entries with a
    null-`field` guard to silence the type checker and make the
    "pricing variables only" contract explicit.
  - Apply Prettier-consistent formatting to the upstream-ratio-sync
    table/columns, channel mutate drawer, system info section,
    tier-expr, and wallet helpers (no behaviour change).

* i18n: add 9 keys across en/zh/fr/ja/ru/vi

  - `{{count}} tiers`, `Billing Process`, `Matched`, `Matched Tier`,
    `Request-based`, `Security verification`, `Time-based`, plus the
    two new Passkey verification description strings.

* 🔧 refactor(default): align upstream price sync, tiered billing, and fetch-models with classic 59337e9

Port and optimize the remaining web/classic features from commit 59337e9 to web/default,
covering upstream price synchronization, tiered billing expressions, model fetching, and
channel preset detection. Improve component architecture, memoization, and i18n coverage.

Upstream Price Sync
- Extend sync to all ratio fields: CacheRatio, CreateCacheRatio, ImageRatio,
  AudioRatio, AudioCompletionRatio in addition to ModelRatio / CompletionRatio
  / ModelPrice
- Add tiered billing sync (billing_mode + billing_expr) with auto-pairing so
  selecting one upstream tier value populates the other from the same source
- Bulk select / unselect per upstream column with indeterminate checkbox state
  reflecting partial selection
- Confidence indicators warn when an upstream entry is heuristically derived
- Conflict confirm dialog gains loading state and disables actions during sync
- Default endpoint per channel: /api/pricing for official preset,
  /api/models.dev for the models.dev preset, /api/v1/models for OpenRouter,
  with the rest falling back to the global default
- Rename tab label from "Upstream sync" to "Upstream price sync" for clarity

Tiered Pricing Editor
- Add `len` (full input length, including cache hits) as a tier-condition
  variable to avoid mis-routing when cache hits reduce `p`
- When inserting a new tier, automatically convert the previous catch-all into
  a bounded tier with a `len <= X` upper bound
- Cap each tier at 0~2 conditions and disable the add-condition button at the
  limit, with an Alert explaining the recommended `len` usage
- Extend presets with Multimodal (img / img_o / ai / ao), Request rule
  (header/param matching), and Time-based (hour / weekday) entries
- Embed an LLM prompt helper that copies a model-aware template for designing
  expressions with ChatGPT / Claude

Fetch Models Dialog
- Add a "Removed Models" tab listing models still in the local selection but
  no longer returned by the upstream listing
- Exclude `model_mapping` source keys from the removed view so aliases never
  appear as missing entries
- Force-remount tab content on tab switch via `key` prop to clear stale state
- Switch count placeholders to `{{count}}` interpolation across "Existing
  Models", "New Models", and "Removed Models" labels

Channel Selector & Constants
- Recognize the models.dev preset (id, base_url, name) alongside the existing
  official-channel preset detection
- Add MODELS_DEV_PRESET_* and OPENROUTER_* constants and reorder
  ENDPOINT_OPTIONS so `pricing` is preferred over `ratio_config`
- Expose the new ratio types in RATIO_TYPE_OPTIONS for the sync filter

Types
- Add optional `type` field to UpstreamChannel for endpoint inference
- Extend RatioType union with create_cache_ratio, image_ratio, audio_ratio,
  audio_completion_ratio, billing_mode, and billing_expr

Code Quality & Performance
- Extract upstream-ratio-sync-helpers.ts to host shared types
  (RatioDifferenceEntry, ModelRow, ResolutionsMap), field ordering
  (RATIO_SYNC_FIELDS, SYNC_FIELD_ORDER, NUMERIC_SYNC_FIELDS), and selection
  logic (getPreferredSyncField, isSelectableUpstreamValue, getSyncFieldLabel)
- Memoize the column definitions in useUpstreamRatioSyncColumns and pull the
  per-cell rendering into a renderUpstreamValue helper to remove inline IIFEs
- Wrap handleBulkSelect / handleBulkUnselect in useCallback for stable refs;
  rename the misleading `_upstream` parameter to `upstream`
- Convert parsedRatios from useCallback (returning a function) to useMemo
  (returning the value) and update all call sites to read it as a value
- Memoize the channels list with useMemo so the endpoint-init effect no
  longer fires on every render due to a fresh `?? []` reference

i18n
- Add and translate new keys ("Upstream price sync", "Audio Ratio", "Audio
  Completion Ratio", "Cache Create Ratio", "Image Ratio", "Expression
  Billing", "Fixed Price", "{{n}} model(s) selected", tier guidance, etc.)
  across en, zh, fr, ja, ru, vi
- Fix truncated keys ("Existing Models (", "New Models (", "Removed Models (")
  to proper {{count}} interpolated forms in every locale
- bun run i18n:sync reports 0 missing and 0 extra keys for every locale

Verification
- bun run typecheck: pass
- bun run lint: pass
- bun run i18n:sync: pass (0 missing / 0 extras across all locales)

* 🐛 fix(default): port classic 73e5557 tiered-billing fixes and dedupe Title-Case ratio i18n keys

Sync the web/classic frontend fixes from upstream merge 73e5557 to
web/default, and clean up duplicated Title-Case ratio labels in the
upstream sync UI that were shadowing the canonical sentence-case i18n
keys.

Cache-token filter for tiered model price (port of 9f8a4ec05)
- The matched-tier breakdown shown in the usage-log details dialog
  and in the log table previously listed every cache-related price
  (Cache Read, Cache Write, Cache Write 1h) regardless of whether
  the request actually consumed cache tokens.
- `getTieredBillingSummary` in `usage-logs/lib/format.ts` now skips
  `cache`-group vars when none of `cache_tokens`,
  `cache_creation_tokens`, `cache_creation_tokens_5m`, or
  `cache_creation_tokens_1h` are positive, mirroring the classic
  `renderTieredModelPrice` / `renderTieredModelPriceSimple` logic.
- Extract `hasAnyCacheTokens(other)` as an exported helper so the
  predicate is defined once.
- Add a `hideCacheColumns?: boolean` prop to
  `DynamicPricingBreakdown` and wire it up from the log details
  dialog so the full tier table hides cache columns under the same
  condition. `model-details.tsx` keeps the default (show all
  configured prices), since that view represents the model's
  pricing structure rather than a specific call.

`tiered_expr` ratio/price fallback during sync delays (port of bee339d27)
- When saving a model in tiered-expression mode, the visual editor
  used to delete every ratio/price map entry for the model and only
  write `billing_setting.billing_mode` /
  `billing_setting.billing_expr`. In multi-instance deployments,
  instances that had not yet observed the billing_setting update
  fell back to ratios that no longer existed, breaking pricing.
- `model-ratio-dialog.tsx`: `handleSubmit` always passes every form
  field (`price`, `ratio`, `cacheRatio`, `createCacheRatio`,
  `completionRatio`, `imageRatio`, `audioRatio`,
  `audioCompletionRatio`) into the data object regardless of
  `pricingMode`, so a switch from per-token to tiered_expr no
  longer drops the previously entered ratios.
- `model-ratio-visual-editor.tsx`:
  - The row builder now also surfaces ratio/price values for
    `tiered_expr` rows, so they survive the edit-and-save round
    trip and the next save.
  - `handleSave` factors out a `setIfPresent` helper and persists
    ratio/price entries for `tiered_expr` models alongside
    billing_mode / billing_expr. These act purely as fallback
    because the backend's `ModelPriceHelper` checks `billing_mode`
    first.
  - Cell rendering mutes ratio/price values whenever the row is
    `tiered_expr` (in addition to the existing per-request
    muting), making it visually clear the values are fallback,
    not the active pricing source.

i18n: dedupe Title-Case ratio labels in upstream sync
- `upstream-ratio-sync` `RATIO_TYPE_OPTIONS` previously used
  Title-Case labels (`Model Ratio`, `Cache Ratio`, `Audio
  Completion Ratio`, …) that were rendered through `t()` but never
  existed as canonical keys in the catalogue. The form-field side
  has used sentence-case (`Model ratio`, `Cache ratio`, …) for
  some time, leaving two parallel translation entries per ratio
  type and causing the upstream sync UI to fall back to the
  English source string in zh/ja/ru/fr/vi.
- Rewrite `RATIO_TYPE_OPTIONS` in
  `system-settings/models/constants.ts` and the conflict-detection
  labels in `upstream-ratio-sync.tsx` to reuse the sentence-case
  keys.
- Drop the duplicate Title-Case entries from every locale and
  promote the better translations onto the surviving sentence-case
  keys (e.g. zh `Image ratio` keeps "图片倍率", `Audio completion
  ratio` keeps "音频补全倍率").
- Add a comment to `RATIO_TYPE_OPTIONS` warning future
  contributors not to switch back to Title Case without updating
  the catalogue.

Note on backend fix 4e93148d9
- The backend portion of the merge (allocating a fresh map in
  `updateConfigFromMap` so removed keys are properly cleared) is
  already on HEAD; no additional change is needed.

Verification
- `bun run typecheck`: pass
- `bun run lint`: pass
- `bun run i18n:sync`: 0 missing / 0 extras across
  en / zh / fr / ja / ru / vi

---------

Co-authored-by: Seefs <40468931+seefs001@users.noreply.github.com>
Co-authored-by: Seefs <i@seefs.me>
Co-authored-by: feitianbubu <feitianbubu@qq.com>
Co-authored-by: Calcium-Ion <i@caion.me>
Co-authored-by: Xyfacai <xyfacai@gmail.com>
Co-authored-by: xiangsx <1984871009@qq.com>
Co-authored-by: 郑伯涛 <351175318@qq.com>
Co-authored-by: RedwindA <austinaosid@gmail.com>
Co-authored-by: dean <1006393151@qq.com>
Co-authored-by: QuentinHsu <xuquentinyang@gmail.com>
Co-authored-by: Bliod <bliod@bliod.lan>
Co-authored-by: Apple\Apple <zeraturing@foxmail.com>
This commit is contained in:
同語
2026-04-28 14:19:19 +08:00
committed by GitHub
parent 9f8a4ec050
commit a42b397607
1290 changed files with 158786 additions and 53 deletions
+116
View File
@@ -0,0 +1,116 @@
import { api } from '@/lib/api'
import type {
LoginPayload,
LoginResponse,
Login2FAResponse,
TwoFAPayload,
RegisterPayload,
ApiResponse,
} from './types'
// ============================================================================
// Authentication APIs
// ============================================================================
// ----------------------------------------------------------------------------
// Login & Logout
// ----------------------------------------------------------------------------
// User login with username and password
export async function login(payload: LoginPayload) {
const turnstile = payload.turnstile ?? ''
const res = await api.post<LoginResponse>(
`/api/user/login?turnstile=${turnstile}`,
{
username: payload.username,
password: payload.password,
}
)
return res.data
}
// Two-factor authentication login
export async function login2fa(payload: TwoFAPayload) {
const res = await api.post<Login2FAResponse>('/api/user/login/2fa', payload)
return res.data
}
// User logout
export async function logout(): Promise<ApiResponse> {
const res = await api.get('/api/user/logout')
return res.data
}
// ----------------------------------------------------------------------------
// Password Management
// ----------------------------------------------------------------------------
// Send password reset email
export async function sendPasswordResetEmail(
email: string,
turnstile?: string
): Promise<ApiResponse> {
const res = await api.get('/api/reset_password', {
params: { email, turnstile },
})
return res.data
}
// ----------------------------------------------------------------------------
// OAuth
// ----------------------------------------------------------------------------
// Start GitHub OAuth flow
export async function githubOAuthStart(clientId: string, state: string) {
const url = `https://github.com/login/oauth/authorize?client_id=${clientId}&state=${state}&scope=user:email`
window.open(url)
}
// Get OAuth state for CSRF protection
export async function getOAuthState(): Promise<string> {
const aff =
typeof window !== 'undefined' ? (localStorage.getItem('aff') ?? '') : ''
const res = await api.get('/api/oauth/state', { params: { aff } })
if (res.data?.success) return res.data.data
return ''
}
// WeChat login by authorization code
export async function wechatLoginByCode(code: string): Promise<ApiResponse> {
const res = await api.get('/api/oauth/wechat', { params: { code } })
return res.data
}
// ----------------------------------------------------------------------------
// Registration
// ----------------------------------------------------------------------------
// User registration
export async function register(payload: RegisterPayload): Promise<ApiResponse> {
const res = await api.post(`/api/user/register`, payload, {
params: { turnstile: payload.turnstile ?? '' },
})
return res.data
}
// Send email verification code
export async function sendEmailVerification(
email: string,
turnstile?: string
): Promise<ApiResponse> {
const res = await api.get('/api/verification', {
params: { email, turnstile },
})
return res.data
}
// Bind email to OAuth account
export async function bindEmail(
email: string,
code: string
): Promise<ApiResponse> {
const res = await api.get('/api/oauth/email/bind', {
params: { email, code },
})
return res.data
}
+44
View File
@@ -0,0 +1,44 @@
import { Link } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { useSystemConfig } from '@/hooks/use-system-config'
import { Skeleton } from '@/components/ui/skeleton'
type AuthLayoutProps = {
children: React.ReactNode
}
export function AuthLayout({ children }: AuthLayoutProps) {
const { t } = useTranslation()
const { systemName, logo, loading } = useSystemConfig()
return (
<div className='relative grid h-svh max-w-none'>
<Link
to='/'
className='absolute top-4 left-4 z-10 flex items-center gap-2 transition-opacity hover:opacity-80 sm:top-8 sm:left-8'
>
<div className='relative h-8 w-8'>
{loading ? (
<Skeleton className='absolute inset-0 rounded-full' />
) : (
<img
src={logo}
alt={t('Logo')}
className='h-8 w-8 rounded-full object-cover'
/>
)}
</div>
{loading ? (
<Skeleton className='h-6 w-24' />
) : (
<h1 className='text-xl font-medium'>{systemName}</h1>
)}
</Link>
<div className='container flex items-center pt-16 sm:pt-0'>
<div className='mx-auto flex w-full flex-col justify-center space-y-2 px-4 py-8 sm:w-[480px] sm:p-8'>
{children}
</div>
</div>
</div>
)
}
@@ -0,0 +1,77 @@
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import type { SystemStatus } from '../types'
interface LegalConsentProps {
status: SystemStatus | null
checked: boolean
onCheckedChange: (nextValue: boolean) => void
className?: string
}
export function LegalConsent({
status,
checked,
onCheckedChange,
className,
}: LegalConsentProps) {
const { t } = useTranslation()
const hasUserAgreement = Boolean(status?.user_agreement_enabled)
const hasPrivacyPolicy = Boolean(status?.privacy_policy_enabled)
if (!hasUserAgreement && !hasPrivacyPolicy) {
return null
}
const handleChange = (value: boolean | 'indeterminate') => {
onCheckedChange(value === true)
}
return (
<div
className={cn(
'border-border/60 bg-muted/40 flex items-start gap-3 rounded-md border p-3',
className
)}
>
<Checkbox
id='legal-consent'
checked={checked}
onCheckedChange={handleChange}
className='mt-0.5'
/>
<Label
htmlFor='legal-consent'
className='text-muted-foreground items-start gap-1 text-left text-xs leading-5 font-normal'
>
<span>
{t('I have read and agree to the')}{' '}
{hasUserAgreement && (
<a
href='/user-agreement'
target='_blank'
rel='noopener noreferrer'
className='text-primary hover:underline'
>
{t('User Agreement')}
</a>
)}
{hasUserAgreement && hasPrivacyPolicy && ' and the '}
{hasPrivacyPolicy && (
<a
href='/privacy-policy'
target='_blank'
rel='noopener noreferrer'
className='text-primary hover:underline'
>
{t('Privacy Policy')}
</a>
)}
.
</span>
</Label>
</div>
)
}
@@ -0,0 +1,106 @@
import { useMemo } from 'react'
import { Loader2, Send, Shield, UserRound, type LucideIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { SiGithub, SiLinux, SiWechat } from 'react-icons/si'
import { AuthLayout } from '../auth-layout'
type OAuthCallbackScreenProps = {
provider: string
mode: 'login' | 'bind'
}
type ProviderMeta = {
label: string
Icon: LucideIcon | ((props: { className?: string }) => React.JSX.Element)
}
const providerDictionary: Record<string, ProviderMeta> = {
github: {
label: 'GitHub',
Icon: (props: { className?: string }) => (
<SiGithub className={props.className} focusable='false' />
),
},
oidc: { label: 'OIDC', Icon: Shield },
linuxdo: {
label: 'LinuxDO',
Icon: (props: { className?: string }) => (
<SiLinux className={props.className} focusable='false' />
),
},
telegram: { label: 'Telegram', Icon: Send },
wechat: {
label: 'WeChat',
Icon: (props: { className?: string }) => (
<SiWechat className={props.className} focusable='false' />
),
},
}
export function OAuthCallbackScreen({
provider,
mode,
}: OAuthCallbackScreenProps) {
const { t } = useTranslation()
const { label, Icon } = useMemo(() => {
const normalized = provider?.toLowerCase() ?? ''
return (
providerDictionary[normalized] || {
label: 'account',
Icon: UserRound,
}
)
}, [provider])
const providerLabel = t(label)
const isBindMode = mode === 'bind'
const headline = isBindMode
? t('Binding your {{provider}} account', { provider: providerLabel })
: t('Signing you in with {{provider}}', { provider: providerLabel })
const description = isBindMode
? t('Hang tight while we securely link this account to your profile.')
: t('Hang tight while we finish connecting your account.')
const secondaryNote = isBindMode
? t(
'You can close this tab once the binding completes or a success message appears in the original window.'
)
: t(
"You'll be redirected automatically. You can return to the previous page if nothing happens after a few seconds."
)
return (
<AuthLayout>
<div className='w-full space-y-8'>
<div className='flex flex-col items-center space-y-4 text-center'>
<div className='bg-muted flex h-16 w-16 items-center justify-center rounded-full'>
<Icon className='h-8 w-8' />
</div>
<div className='space-y-2'>
<h2 className='text-center text-2xl font-semibold tracking-tight'>
{headline}
</h2>
<p className='text-muted-foreground text-sm sm:text-base'>
{description}
</p>
</div>
</div>
<div className='space-y-4 text-center'>
<div className='flex items-center justify-center gap-2 text-sm font-medium'>
<Loader2 className='h-4 w-4 animate-spin' />
<span>{t('Processing OAuth response...')}</span>
</div>
<p className='text-muted-foreground text-sm'>{secondaryNote}</p>
<p className='text-muted-foreground text-xs'>
{t(
'This may take a few moments while we validate the request and update your session.'
)}
</p>
</div>
</div>
</AuthLayout>
)
}
@@ -0,0 +1,152 @@
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import {
IconDiscord,
IconGithub,
IconLinuxDo,
IconWeChat,
} from '@/assets/brand-icons'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { useOAuthLogin } from '../hooks/use-oauth-login'
import type { SystemStatus } from '../types'
type OAuthProvidersProps = {
status: SystemStatus | null
disabled?: boolean
className?: string
onWeChatLogin?: () => void
isWeChatLoading?: boolean
}
type ProviderButton = {
key: string
label: string
onClick: () => void
icon?: ReactNode
disabled?: boolean
}
export function OAuthProviders({
status,
disabled = false,
className,
onWeChatLogin,
isWeChatLoading = false,
}: OAuthProvidersProps) {
const { t } = useTranslation()
const {
isLoading,
githubButtonText,
githubButtonDisabled,
handleGitHubLogin,
handleDiscordLogin,
handleOIDCLogin,
handleLinuxDOLogin,
handleTelegramLogin,
handleCustomOAuthLogin,
} = useOAuthLogin(status)
const providerButtons: ProviderButton[] = []
if (status?.wechat_login && onWeChatLogin) {
providerButtons.push({
key: 'wechat',
label: t('Continue with WeChat'),
onClick: onWeChatLogin,
icon: <IconWeChat className='h-4 w-4' />,
disabled: isWeChatLoading,
})
}
if (status?.github_oauth) {
providerButtons.push({
key: 'github',
label: githubButtonText || t('Continue with GitHub'),
onClick: handleGitHubLogin,
icon: <IconGithub className='h-4 w-4' />,
disabled: githubButtonDisabled,
})
}
if (status?.discord_oauth) {
providerButtons.push({
key: 'discord',
label: t('Continue with Discord'),
onClick: handleDiscordLogin,
icon: <IconDiscord className='h-4 w-4' />,
})
}
if (status?.oidc_enabled) {
providerButtons.push({
key: 'oidc',
label: t('Continue with OIDC'),
onClick: handleOIDCLogin,
})
}
if (status?.linuxdo_oauth) {
providerButtons.push({
key: 'linuxdo',
label: t('Continue with LinuxDO'),
onClick: handleLinuxDOLogin,
icon: <IconLinuxDo className='h-4 w-4' />,
})
}
if (status?.telegram_oauth) {
providerButtons.push({
key: 'telegram',
label: t('Continue with Telegram'),
onClick: handleTelegramLogin,
})
}
// Custom OAuth providers
const customProviders = status?.custom_oauth_providers
if (customProviders && customProviders.length > 0) {
for (const provider of customProviders) {
providerButtons.push({
key: `custom-${provider.slug}`,
label: t('Continue with {{name}}', { name: provider.name }),
onClick: () => handleCustomOAuthLogin(provider),
})
}
}
if (providerButtons.length === 0) return null
return (
<div className={cn('space-y-3', className)}>
<div className='relative'>
<div className='absolute inset-0 flex items-center'>
<span className='w-full border-t' />
</div>
<div className='relative flex justify-center text-xs uppercase'>
<span className='bg-background text-muted-foreground px-2'>
{t('Or continue with')}
</span>
</div>
</div>
<div className='flex flex-col gap-2'>
{providerButtons.map(
({ key, label, onClick, icon, disabled: extraDisabled }) => (
<Button
key={key}
variant='outline'
type='button'
disabled={disabled || isLoading || extraDisabled}
onClick={onClick}
className='h-11 w-full justify-center gap-2 rounded-lg'
>
{icon}
{label}
</Button>
)
)}
</div>
</div>
)
}
@@ -0,0 +1,74 @@
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import type { SystemStatus } from '../types'
interface TermsFooterProps {
variant?: 'sign-in' | 'sign-up'
className?: string
status?: SystemStatus | null
}
export function TermsFooter({
variant = 'sign-in',
className,
status,
}: TermsFooterProps) {
const { t } = useTranslation()
const text =
variant === 'sign-in'
? 'By clicking sign in, you agree to our'
: 'By creating an account, you agree to our'
const hasUserAgreement = Boolean(status?.user_agreement_enabled)
const hasPrivacyPolicy = Boolean(status?.privacy_policy_enabled)
if (!hasUserAgreement && !hasPrivacyPolicy) {
return null
}
const agreementLink = {
label: 'User Agreement',
href: '/user-agreement',
}
const privacyLink = {
label: 'Privacy Policy',
href: '/privacy-policy',
}
const activeLinks =
hasUserAgreement || hasPrivacyPolicy
? ([
hasUserAgreement ? agreementLink : null,
hasPrivacyPolicy ? privacyLink : null,
].filter(Boolean) as Array<{ label: string; href: string }>)
: [agreementLink, privacyLink]
const [firstLink, secondLink] = activeLinks
return (
<p className={cn('text-muted-foreground text-center text-xs', className)}>
{text}{' '}
{firstLink && (
<a
href={firstLink.href}
className='hover:text-primary underline underline-offset-4'
>
{firstLink.label}
</a>
)}
{secondLink && (
<>
{' '}
{t('and')}{' '}
<a
href={secondLink.href}
className='hover:text-primary underline underline-offset-4'
>
{secondLink.label}
</a>
</>
)}
.
</p>
)
}
+63
View File
@@ -0,0 +1,63 @@
import { z } from 'zod'
// ============================================================================
// Form Schemas
// ============================================================================
export const loginFormSchema = z.object({
username: z.string().min(1, 'Please enter your username or email'),
password: z
.string()
.min(1, 'Please enter your password')
.min(8, 'Password must be at least 8 characters long'),
})
export const registerFormSchema = z
.object({
username: z.string().min(1, 'Please enter your username'),
email: z.string().optional(),
password: z
.string()
.min(1, 'Please enter your password')
.min(8, 'Password must be at least 8 characters long')
.max(20, 'Password must be at most 20 characters long'),
confirmPassword: z.string().min(1, 'Please confirm your password'),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match.",
path: ['confirmPassword'],
})
export const forgotPasswordFormSchema = z.object({
email: z.string().email({
message: 'Please enter a valid email address',
}),
})
export const otpFormSchema = z.object({
otp: z.string().min(1, 'Please enter a code.'),
})
// ============================================================================
// Validation Constants
// ============================================================================
export const PASSWORD_MIN_LENGTH = 8
export const PASSWORD_MAX_LENGTH = 20
export const OTP_LENGTH = 6
export const BACKUP_CODE_LENGTH = 9 // XXXX-XXXX format
export const BACKUP_CODE_REGEX = /^[A-Z0-9]{4}-[A-Z0-9]{4}$/i
export const OTP_REGEX = /^\d{6}$/
// ============================================================================
// Countdown Constants
// ============================================================================
export const EMAIL_VERIFICATION_COUNTDOWN = 30 // seconds
export const PASSWORD_RESET_COUNTDOWN = 30 // seconds
// ============================================================================
// OAuth Constants
// ============================================================================
export const OAUTH_BIND_STORAGE_KEY = 'oauth:binding:result'
@@ -0,0 +1,108 @@
import { useState } from 'react'
import type { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { ArrowRight, Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { useCountdown } from '@/hooks/use-countdown'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Turnstile } from '@/components/turnstile'
import { sendPasswordResetEmail } from '@/features/auth/api'
import {
forgotPasswordFormSchema,
PASSWORD_RESET_COUNTDOWN,
} from '@/features/auth/constants'
import { useTurnstile } from '@/features/auth/hooks/use-turnstile'
export function ForgotPasswordForm({
className,
...props
}: React.HTMLAttributes<HTMLFormElement>) {
const { t } = useTranslation()
const [isLoading, setIsLoading] = useState(false)
const {
isTurnstileEnabled,
turnstileSiteKey,
turnstileToken,
setTurnstileToken,
validateTurnstile,
} = useTurnstile()
const {
secondsLeft,
isActive,
start: startCountdown,
} = useCountdown({ initialSeconds: PASSWORD_RESET_COUNTDOWN })
const form = useForm<z.infer<typeof forgotPasswordFormSchema>>({
resolver: zodResolver(forgotPasswordFormSchema),
defaultValues: { email: '' },
})
async function onSubmit(data: z.infer<typeof forgotPasswordFormSchema>) {
if (!validateTurnstile()) return
setIsLoading(true)
try {
const res = await sendPasswordResetEmail(data.email, turnstileToken)
if (res?.success) {
form.reset()
startCountdown()
toast.success(t('Reset email sent, please check your inbox'))
}
} catch (_error) {
// Errors are handled by global interceptor
} finally {
setIsLoading(false)
}
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className={cn('grid gap-2', className)}
{...props}
>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder='name@example.com' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button className='mt-2' disabled={isLoading || isActive}>
{isActive ? `Resend (${secondsLeft}s)` : 'Send reset email'}
{isLoading ? <Loader2 className='animate-spin' /> : <ArrowRight />}
</Button>
{isTurnstileEnabled && (
<div className='mt-2'>
<Turnstile
siteKey={turnstileSiteKey}
onVerify={setTurnstileToken}
/>
</div>
)}
</form>
</Form>
)
}
+36
View File
@@ -0,0 +1,36 @@
import { Link } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { AuthLayout } from '../auth-layout'
import { ForgotPasswordForm } from './components/forgot-password-form'
export function ForgotPassword() {
const { t } = useTranslation()
return (
<AuthLayout>
<div className='w-full space-y-8'>
<div className='space-y-3'>
<h2 className='text-center text-2xl font-semibold tracking-tight sm:text-left'>
{t('Forgot password')}
</h2>
<p className='text-muted-foreground text-left text-sm sm:text-base'>
{t(
'Enter your registered email and we will send you a link to reset your password.'
)}
</p>
<p className='text-muted-foreground text-left text-sm sm:text-base'>
{t("Don't have an account?")}{' '}
<Link
to='/sign-up'
className='hover:text-primary font-medium underline underline-offset-4'
>
{t('Sign up')}
</Link>
.
</p>
</div>
<ForgotPasswordForm className='space-y-0' />
</div>
</AuthLayout>
)
}
@@ -0,0 +1,86 @@
import { useNavigate } from '@tanstack/react-router'
import i18n from 'i18next'
import { useAuthStore } from '@/stores/auth-store'
import { getSelf } from '@/lib/api'
import type { User } from '@/features/users/types'
import { saveUserId } from '../lib/storage'
/**
* Hook for handling authentication redirects and user data management
*/
export function useAuthRedirect() {
const navigate = useNavigate()
const { auth } = useAuthStore()
/**
* Handle successful login
* @param userData - Optional user data from login response
* @param redirectTo - Redirect path after login
*/
const handleLoginSuccess = async (
userData?: { id?: number } | null,
redirectTo?: string
) => {
// Save user ID if available
if (userData?.id) {
saveUserId(userData.id)
}
// Fetch and set user data
try {
const self = await getSelf()
if (self?.success && self.data) {
const user = self.data as User
auth.setUser(user)
// Update user ID if not already set
if (user.id) {
saveUserId(user.id)
}
// Restore saved language preference
const savedLang = (user as Record<string, unknown>).language as
| string
| undefined
if (savedLang && savedLang !== i18n.language) {
i18n.changeLanguage(savedLang)
}
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to fetch user data:', error)
}
// Navigate to target page
const targetPath = redirectTo || '/dashboard'
navigate({ to: targetPath, replace: true })
}
/**
* Redirect to 2FA page
*/
const redirectTo2FA = () => {
navigate({ to: '/otp', replace: true })
}
/**
* Redirect to login page
*/
const redirectToLogin = () => {
navigate({ to: '/sign-in', replace: true })
}
/**
* Redirect to register page
*/
const redirectToRegister = () => {
navigate({ to: '/sign-up', replace: true })
}
return {
handleLoginSuccess,
redirectTo2FA,
redirectToLogin,
redirectToRegister,
}
}
@@ -0,0 +1,61 @@
import { useState } from 'react'
import i18next from 'i18next'
import { toast } from 'sonner'
import { useCountdown } from '@/hooks/use-countdown'
import { sendEmailVerification } from '../api'
import { EMAIL_VERIFICATION_COUNTDOWN } from '../constants'
interface UseEmailVerificationOptions {
turnstileToken?: string
validateTurnstile?: () => boolean
}
/**
* Hook for managing email verification code sending
*/
export function useEmailVerification(options?: UseEmailVerificationOptions) {
const [isSending, setIsSending] = useState(false)
const {
secondsLeft,
isActive,
start: startCountdown,
} = useCountdown({ initialSeconds: EMAIL_VERIFICATION_COUNTDOWN })
/**
* Send verification code to email
*/
const sendCode = async (email: string) => {
if (!email) {
toast.error(i18next.t('Please enter your email first'))
return false
}
// Validate turnstile if validation function is provided
if (options?.validateTurnstile && !options.validateTurnstile()) {
return false
}
setIsSending(true)
try {
const res = await sendEmailVerification(email, options?.turnstileToken)
if (res?.success) {
startCountdown()
toast.success(i18next.t('Verification email sent'))
return true
}
return false
} catch (_error) {
// Errors are handled by global interceptor
return false
} finally {
setIsSending(false)
}
}
return {
isSending,
secondsLeft,
isActive,
sendCode,
}
}
+217
View File
@@ -0,0 +1,217 @@
import { useState, useRef, useEffect } from 'react'
import type { AxiosRequestConfig } from 'axios'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { useAuthStore } from '@/stores/auth-store'
import { api } from '@/lib/api'
import { getOAuthState } from '../api'
import {
buildGitHubOAuthUrl,
buildDiscordOAuthUrl,
buildOIDCOAuthUrl,
buildLinuxDOOAuthUrl,
} from '../lib/oauth'
import type { SystemStatus, CustomOAuthProviderInfo } from '../types'
type LogoutRequestConfig = AxiosRequestConfig & {
skipErrorHandler?: boolean
}
/**
* Hook for managing OAuth login
*/
export function useOAuthLogin(status: SystemStatus | null) {
const { t } = useTranslation()
const [isLoading, setIsLoading] = useState(false)
const [githubButtonText, setGithubButtonText] = useState('')
const [githubButtonDisabled, setGithubButtonDisabled] = useState(false)
const githubTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const { auth } = useAuthStore()
useEffect(() => {
setGithubButtonText(t('Continue with GitHub'))
return () => {
if (githubTimeoutRef.current) {
clearTimeout(githubTimeoutRef.current)
}
}
}, [t])
const resetSession = async () => {
try {
auth.reset()
} catch (_error) {
// ignore store reset errors
}
try {
await api.get('/api/user/logout', {
skipErrorHandler: true,
} as LogoutRequestConfig)
} catch (_error) {
// ignore logout errors
}
}
const handleGitHubLogin = async () => {
if (!status?.github_client_id) return
if (githubButtonDisabled) return
setIsLoading(true)
setGithubButtonDisabled(true)
setGithubButtonText(t('Redirecting to GitHub...'))
if (githubTimeoutRef.current) {
clearTimeout(githubTimeoutRef.current)
}
githubTimeoutRef.current = setTimeout(() => {
setIsLoading(false)
setGithubButtonText(
t('Request timed out, please refresh and restart GitHub login')
)
setGithubButtonDisabled(true)
}, 20000)
try {
await resetSession()
const state = await getOAuthState()
if (!state) {
toast.error(t('Failed to initialize OAuth'))
if (githubTimeoutRef.current) {
clearTimeout(githubTimeoutRef.current)
}
setIsLoading(false)
setGithubButtonText(t('Continue with GitHub'))
setGithubButtonDisabled(false)
return
}
const url = buildGitHubOAuthUrl(status.github_client_id, state)
window.open(url, '_self')
} catch (_error) {
toast.error(t('Failed to start GitHub login'))
if (githubTimeoutRef.current) {
clearTimeout(githubTimeoutRef.current)
}
setIsLoading(false)
setGithubButtonText(t('Continue with GitHub'))
setGithubButtonDisabled(false)
}
}
const handleDiscordLogin = async () => {
if (!status?.discord_client_id) return
setIsLoading(true)
try {
await resetSession()
const state = await getOAuthState()
if (!state) {
toast.error(t('Failed to initialize OAuth'))
return
}
const url = buildDiscordOAuthUrl(status.discord_client_id, state)
window.open(url, '_self')
} catch (_error) {
toast.error(t('Failed to start Discord login'))
} finally {
setIsLoading(false)
}
}
const handleOIDCLogin = async () => {
if (!status?.oidc_authorization_endpoint || !status?.oidc_client_id) return
setIsLoading(true)
try {
await resetSession()
const state = await getOAuthState()
if (!state) {
toast.error(t('Failed to initialize OAuth'))
return
}
const url = buildOIDCOAuthUrl(
status.oidc_authorization_endpoint,
status.oidc_client_id,
state
)
window.open(url, '_self')
} catch (_error) {
toast.error(t('Failed to start OIDC login'))
} finally {
setIsLoading(false)
}
}
const handleLinuxDOLogin = async () => {
if (!status?.linuxdo_client_id) return
setIsLoading(true)
try {
await resetSession()
const state = await getOAuthState()
if (!state) {
toast.error(t('Failed to initialize OAuth'))
return
}
const url = buildLinuxDOOAuthUrl(status.linuxdo_client_id, state)
window.open(url, '_self')
} catch (_error) {
toast.error(t('Failed to start LinuxDO login'))
} finally {
setIsLoading(false)
}
}
const handleTelegramLogin = () => {
toast.info(t('Telegram login requires widget integration; coming soon'))
}
const handleCustomOAuthLogin = async (provider: CustomOAuthProviderInfo) => {
if (!provider.authorization_endpoint || !provider.client_id) return
setIsLoading(true)
try {
await resetSession()
const state = await getOAuthState()
if (!state) {
toast.error(t('Failed to initialize OAuth'))
return
}
const redirectUri = `${window.location.origin}/oauth/${provider.slug}`
const url = new URL(provider.authorization_endpoint)
url.searchParams.set('client_id', provider.client_id)
url.searchParams.set('redirect_uri', redirectUri)
url.searchParams.set('response_type', 'code')
url.searchParams.set('state', state)
if (provider.scopes) {
url.searchParams.set('scope', provider.scopes)
}
window.open(url.toString(), '_self')
} catch (_error) {
toast.error(
t('Failed to start {{provider}} login', { provider: provider.name })
)
} finally {
setIsLoading(false)
}
}
return {
isLoading,
githubButtonText,
githubButtonDisabled,
handleGitHubLogin,
handleDiscordLogin,
handleOIDCLogin,
handleLinuxDOLogin,
handleTelegramLogin,
handleCustomOAuthLogin,
}
}
+38
View File
@@ -0,0 +1,38 @@
import { useState } from 'react'
import i18next from 'i18next'
import { toast } from 'sonner'
import { useStatus } from '@/hooks/use-status'
/**
* Hook for managing Turnstile verification
*/
export function useTurnstile() {
const { status } = useStatus()
const [turnstileToken, setTurnstileToken] = useState('')
const isTurnstileEnabled = !!(
status?.turnstile_check && status?.turnstile_site_key
)
const turnstileSiteKey = status?.turnstile_site_key || ''
/**
* Validate if turnstile is ready when required
*/
const validateTurnstile = (): boolean => {
if (isTurnstileEnabled && !turnstileToken) {
toast.info(
i18next.t('Please wait a moment, human check is initializing...')
)
return false
}
return true
}
return {
isTurnstileEnabled,
turnstileSiteKey,
turnstileToken,
setTurnstileToken,
validateTurnstile,
}
}
+105
View File
@@ -0,0 +1,105 @@
// ============================================================================
// API Functions
// ============================================================================
export {
login,
login2fa,
logout,
register,
sendPasswordResetEmail,
sendEmailVerification,
bindEmail,
getOAuthState,
githubOAuthStart,
wechatLoginByCode,
} from './api'
// ============================================================================
// Types
// ============================================================================
export type {
LoginPayload,
LoginResponse,
Login2FAResponse,
TwoFAPayload,
RegisterPayload,
PasswordResetPayload,
EmailVerificationPayload,
BindEmailPayload,
ApiResponse,
SystemStatus,
OAuthProvider,
AuthFormProps,
} from './types'
// ============================================================================
// Constants & Schemas
// ============================================================================
export {
loginFormSchema,
registerFormSchema,
forgotPasswordFormSchema,
otpFormSchema,
PASSWORD_MIN_LENGTH,
PASSWORD_MAX_LENGTH,
OTP_LENGTH,
BACKUP_CODE_LENGTH,
BACKUP_CODE_REGEX,
OTP_REGEX,
EMAIL_VERIFICATION_COUNTDOWN,
PASSWORD_RESET_COUNTDOWN,
} from './constants'
// ============================================================================
// Utilities
// ============================================================================
export {
buildGitHubOAuthUrl,
buildDiscordOAuthUrl,
buildOIDCOAuthUrl,
buildLinuxDOOAuthUrl,
getAvailableOAuthProviders,
hasOAuthProviders,
} from './lib/oauth'
export {
saveUserId,
getUserId,
removeUserId,
getAffiliateCode,
saveAffiliateCode,
} from './lib/storage'
export {
isValidOTP,
isValidBackupCode,
formatBackupCode,
cleanBackupCode,
isValidEmail,
} from './lib/validation'
// ============================================================================
// Hooks
// ============================================================================
export { useTurnstile } from './hooks/use-turnstile'
export { useOAuthLogin } from './hooks/use-oauth-login'
export { useAuthRedirect } from './hooks/use-auth-redirect'
export { useEmailVerification } from './hooks/use-email-verification'
// ============================================================================
// Components
// ============================================================================
export { AuthLayout } from './auth-layout'
export { OAuthProviders } from './components/oauth-providers'
export { TermsFooter } from './components/terms-footer'
export { LegalConsent } from './components/legal-consent'
export { SignIn } from './sign-in'
export { SignUp } from './sign-up'
export { ForgotPassword } from './forgot-password'
export { Otp } from './otp'
+85
View File
@@ -0,0 +1,85 @@
import type { SystemStatus, OAuthProvider } from '../types'
export {
buildGitHubOAuthUrl,
buildDiscordOAuthUrl,
buildOIDCOAuthUrl,
buildLinuxDOOAuthUrl,
} from '@/lib/oauth'
// ============================================================================
// OAuth Providers Utilities
// ============================================================================
/**
* Get available OAuth providers from system status
*/
export function getAvailableOAuthProviders(
status: SystemStatus | null
): OAuthProvider[] {
if (!status) return []
const providers: OAuthProvider[] = []
if (status.github_oauth) {
providers.push({
name: 'GitHub',
type: 'github',
enabled: true,
clientId: status.github_client_id,
})
}
if (status.discord_oauth) {
providers.push({
name: 'Discord',
type: 'discord',
enabled: true,
clientId: status.discord_client_id,
})
}
if (status.oidc_enabled) {
providers.push({
name: 'OIDC',
type: 'oidc',
enabled: true,
clientId: status.oidc_client_id,
authEndpoint: status.oidc_authorization_endpoint,
})
}
if (status.linuxdo_oauth) {
providers.push({
name: 'LinuxDO',
type: 'linuxdo',
enabled: true,
clientId: status.linuxdo_client_id,
})
}
if (status.telegram_oauth) {
providers.push({
name: 'Telegram',
type: 'telegram',
enabled: true,
})
}
return providers
}
/**
* Check if any OAuth provider is available
*/
export function hasOAuthProviders(status: SystemStatus | null): boolean {
if (!status) return false
return !!(
status.github_oauth ||
status.discord_oauth ||
status.oidc_enabled ||
status.linuxdo_oauth ||
status.telegram_oauth ||
status.wechat_login
)
}
+88
View File
@@ -0,0 +1,88 @@
/**
* Utilities for managing authentication-related browser storage
*/
// ============================================================================
// LocalStorage Keys
// ============================================================================
const STORAGE_KEYS = {
USER_ID: 'uid',
AFFILIATE: 'aff',
STATUS: 'status',
} as const
// ============================================================================
// User ID Storage
// ============================================================================
/**
* Save user ID to localStorage
*/
export function saveUserId(userId: number | string): void {
if (typeof window === 'undefined') return
try {
window.localStorage.setItem(STORAGE_KEYS.USER_ID, String(userId))
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to save user ID:', error)
}
}
/**
* Get user ID from localStorage
*/
export function getUserId(): string | null {
if (typeof window === 'undefined') return null
try {
return window.localStorage.getItem(STORAGE_KEYS.USER_ID)
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to get user ID:', error)
return null
}
}
/**
* Remove user ID from localStorage
*/
export function removeUserId(): void {
if (typeof window === 'undefined') return
try {
window.localStorage.removeItem(STORAGE_KEYS.USER_ID)
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to remove user ID:', error)
}
}
// ============================================================================
// Affiliate Code Storage
// ============================================================================
/**
* Get affiliate code from localStorage
*/
export function getAffiliateCode(): string {
if (typeof window === 'undefined') return ''
try {
return window.localStorage.getItem(STORAGE_KEYS.AFFILIATE) ?? ''
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to get affiliate code:', error)
return ''
}
}
/**
* Save affiliate code to localStorage
*/
export function saveAffiliateCode(code: string): void {
if (typeof window === 'undefined') return
try {
window.localStorage.setItem(STORAGE_KEYS.AFFILIATE, code)
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to save affiliate code:', error)
}
}
+62
View File
@@ -0,0 +1,62 @@
import { BACKUP_CODE_REGEX, OTP_REGEX } from '../constants'
/**
* Validation utilities for authentication forms
*/
// ============================================================================
// OTP Validation
// ============================================================================
/**
* Validate OTP code (6 digits)
*/
export function isValidOTP(code: string): boolean {
return OTP_REGEX.test(code)
}
/**
* Validate backup code (XXXX-XXXX format)
*/
export function isValidBackupCode(code: string): boolean {
return BACKUP_CODE_REGEX.test(code)
}
/**
* Format backup code with hyphen (XXXX-XXXX)
*/
export function formatBackupCode(value: string): string {
// Remove all non-alphanumeric characters and convert to uppercase
let cleaned = value.toUpperCase().replace(/[^A-Z0-9]/g, '')
// Limit to 8 characters
if (cleaned.length > 8) {
cleaned = cleaned.slice(0, 8)
}
// Add hyphen after 4th character
if (cleaned.length > 4) {
return cleaned.slice(0, 4) + '-' + cleaned.slice(4)
}
return cleaned
}
/**
* Remove hyphens from backup code before sending to server
*/
export function cleanBackupCode(code: string): string {
return code.replace(/-/g, '')
}
// ============================================================================
// Email Validation
// ============================================================================
/**
* Basic email validation
*/
export function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
@@ -0,0 +1,215 @@
import { useState } from 'react'
import type { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { useAuthStore } from '@/stores/auth-store'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
InputOTPSeparator,
} from '@/components/ui/input-otp'
import { login2fa } from '@/features/auth/api'
import {
otpFormSchema,
OTP_LENGTH,
BACKUP_CODE_LENGTH,
} from '@/features/auth/constants'
import { useAuthRedirect } from '@/features/auth/hooks/use-auth-redirect'
import { saveUserId } from '@/features/auth/lib/storage'
import {
isValidOTP,
isValidBackupCode,
formatBackupCode,
cleanBackupCode,
} from '@/features/auth/lib/validation'
import type { User } from '@/features/users/types'
type OtpFormProps = React.HTMLAttributes<HTMLFormElement>
export function OtpForm({ className, ...props }: OtpFormProps) {
const { t } = useTranslation()
const [isLoading, setIsLoading] = useState(false)
const [useBackupCode, setUseBackupCode] = useState(false)
const { auth } = useAuthStore()
const { redirectToLogin } = useAuthRedirect()
const form = useForm<z.infer<typeof otpFormSchema>>({
resolver: zodResolver(otpFormSchema),
defaultValues: { otp: '' },
})
const otp = form.watch('otp')
async function onSubmit(data: z.infer<typeof otpFormSchema>) {
// Validate based on mode
if (useBackupCode) {
if (!isValidBackupCode(data.otp)) {
toast.error(t('Backup code must be in format XXXX-XXXX'))
return
}
} else {
if (!isValidOTP(data.otp)) {
toast.error(t('Verification code must be 6 digits'))
return
}
}
setIsLoading(true)
try {
// Remove all hyphens from backup code before sending to backend
const code = useBackupCode ? cleanBackupCode(data.otp) : data.otp
const res = await login2fa({ code })
if (!res.success) {
toast.error(res.message || t('Invalid code'))
return
}
// Handle user data from 2FA login response
const userData = res.data
if (!userData) {
throw new Error('No user data received from login')
}
// Update auth store
auth.setUser(userData as User)
// Store user ID in localStorage for compatibility
if (userData.id) {
saveUserId(userData.id)
}
toast.success(t('Signed in'))
redirectToLogin() // This will redirect to dashboard via the redirect logic
} catch (error) {
// eslint-disable-next-line no-console
console.error('2FA verification error:', error)
const errorMessage =
error instanceof Error ? error.message : t('Verification failed')
toast.error(errorMessage)
} finally {
setIsLoading(false)
}
}
function handleToggleMode() {
setUseBackupCode(!useBackupCode)
form.setValue('otp', '')
}
function handleBackToLogin() {
redirectToLogin()
}
const isFormValid = useBackupCode
? otp.length >= BACKUP_CODE_LENGTH
: otp.length >= OTP_LENGTH
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className={cn('grid gap-4', className)}
{...props}
>
<FormField
control={form.control}
name='otp'
render={({ field }) => (
<FormItem>
<FormLabel>
{useBackupCode ? t('Backup Code') : t('Verification Code')}
</FormLabel>
<FormControl>
{useBackupCode ? (
<Input
placeholder={t('Enter backup code (e.g., CAWD-OQDV)')}
{...field}
maxLength={BACKUP_CODE_LENGTH}
autoComplete='off'
className='font-mono uppercase'
onChange={(e) => {
const formatted = formatBackupCode(e.target.value)
field.onChange(formatted)
}}
/>
) : (
<InputOTP
maxLength={OTP_LENGTH}
{...field}
containerClassName='justify-between sm:[&>[data-slot="input-otp-group"]>div]:w-12'
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
)}
</FormControl>
<FormDescription className='text-muted-foreground text-xs'>
{useBackupCode
? t('Each backup code can only be used once.')
: t('Verification code updates every 30 seconds.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button className='mt-2 w-full' disabled={!isFormValid || isLoading}>
{isLoading ? <Loader2 className='h-4 w-4 animate-spin' /> : null}
{t('Verify and Sign In')}
</Button>
<div className='flex items-center justify-center gap-2 text-sm'>
<Button
type='button'
variant='link'
size='sm'
className='text-primary h-auto p-0'
onClick={handleToggleMode}
>
{useBackupCode ? t('Use authenticator code') : t('Use backup code')}
</Button>
<span className='text-muted-foreground'>·</span>
<Button
type='button'
variant='link'
size='sm'
className='text-primary h-auto p-0'
onClick={handleBackToLogin}
>
{t('Back to login')}
</Button>
</div>
</form>
</Form>
)
}
+34
View File
@@ -0,0 +1,34 @@
import { Link } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { AuthLayout } from '../auth-layout'
import { OtpForm } from './components/otp-form'
export function Otp() {
const { t } = useTranslation()
return (
<AuthLayout>
<div className='w-full space-y-8'>
<div className='space-y-3'>
<h2 className='text-center text-2xl font-semibold tracking-tight sm:text-left'>
{t('Two-factor Authentication')}
</h2>
<p className='text-muted-foreground text-left text-sm sm:text-base'>
{t('Please enter the authentication code.')}
</p>
<p className='text-muted-foreground text-left text-sm sm:text-base'>
{t('Session expired?')}{' '}
<Link
to='/sign-in'
className='hover:text-primary font-medium underline underline-offset-4'
>
{t('Re-login')}
</Link>
.
</p>
</div>
<OtpForm />
</div>
</AuthLayout>
)
}
+69
View File
@@ -0,0 +1,69 @@
import { api } from '@/lib/api'
import type { ApiResponse, PasskeyOptionsPayload, PasskeyStatus } from './types'
export async function getPasskeyStatus(): Promise<ApiResponse<PasskeyStatus>> {
const res = await api.get<ApiResponse<PasskeyStatus>>('/api/user/passkey')
return res.data
}
export async function beginPasskeyRegistration(): Promise<
ApiResponse<PasskeyOptionsPayload>
> {
const res = await api.post<ApiResponse<PasskeyOptionsPayload>>(
'/api/user/passkey/register/begin'
)
return res.data
}
export async function finishPasskeyRegistration(
payload: Record<string, unknown>
): Promise<ApiResponse> {
const res = await api.post<ApiResponse>(
'/api/user/passkey/register/finish',
payload
)
return res.data
}
export async function deletePasskey(): Promise<ApiResponse> {
const res = await api.delete<ApiResponse>('/api/user/passkey')
return res.data
}
export async function beginPasskeyLogin(): Promise<
ApiResponse<PasskeyOptionsPayload>
> {
const res = await api.post<ApiResponse<PasskeyOptionsPayload>>(
'/api/user/passkey/login/begin'
)
return res.data
}
export async function finishPasskeyLogin(
payload: Record<string, unknown>
): Promise<ApiResponse> {
const res = await api.post<ApiResponse>(
'/api/user/passkey/login/finish',
payload
)
return res.data
}
export async function beginPasskeyVerification(): Promise<
ApiResponse<PasskeyOptionsPayload>
> {
const res = await api.post<ApiResponse<PasskeyOptionsPayload>>(
'/api/user/passkey/verify/begin'
)
return res.data
}
export async function finishPasskeyVerification(
payload: Record<string, unknown>
): Promise<ApiResponse> {
const res = await api.post<ApiResponse>(
'/api/user/passkey/verify/finish',
payload
)
return res.data
}
@@ -0,0 +1,169 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import i18next from 'i18next'
import { toast } from 'sonner'
import {
buildRegistrationResult,
createCredential,
isPasskeySupported as detectPasskeySupport,
prepareCredentialCreationOptions,
} from '@/lib/passkey'
import {
beginPasskeyRegistration,
deletePasskey,
finishPasskeyRegistration,
getPasskeyStatus,
} from '../api'
import type { PasskeyStatus } from '../types'
interface UsePasskeyManagementOptions {
onStatusChange?: (status: PasskeyStatus | null) => void
}
export function usePasskeyManagement(
options: UsePasskeyManagementOptions = {}
) {
const { onStatusChange } = options
const [status, setStatus] = useState<PasskeyStatus | null>(null)
const [loading, setLoading] = useState(true)
const [registering, setRegistering] = useState(false)
const [removing, setRemoving] = useState(false)
const [supported, setSupported] = useState(false)
const fetchStatus = useCallback(async () => {
try {
setLoading(true)
const res = await getPasskeyStatus()
if (res.success) {
setStatus(res.data ?? null)
onStatusChange?.(res.data ?? null)
} else {
setStatus(null)
toast.error(res.message || i18next.t('Failed to load Passkey status'))
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('[Passkey] Failed to fetch status', error)
toast.error(i18next.t('Failed to load Passkey status'))
setStatus(null)
} finally {
setLoading(false)
}
}, [onStatusChange])
useEffect(() => {
fetchStatus()
}, [fetchStatus])
useEffect(() => {
detectPasskeySupport()
.then(setSupported)
.catch(() => setSupported(false))
}, [])
const register = useCallback(async () => {
if (!supported) {
toast.error(i18next.t('This device does not support Passkey'))
return false
}
if (!navigator?.credentials) {
toast.error(i18next.t('Passkey is not supported in this environment'))
return false
}
setRegistering(true)
try {
const beginResponse = await beginPasskeyRegistration()
if (!beginResponse.success) {
toast.error(
beginResponse.message ||
i18next.t('Failed to start Passkey registration')
)
return false
}
const publicKey = prepareCredentialCreationOptions(
beginResponse.data?.options ?? beginResponse.data
)
const credential = (await createCredential(
publicKey
)) as PublicKeyCredential | null
if (!credential) {
toast.error(i18next.t('Passkey registration was cancelled'))
return false
}
const attestation = buildRegistrationResult(credential)
if (!attestation) {
toast.error(i18next.t('Invalid Passkey registration response'))
return false
}
const finishResponse = await finishPasskeyRegistration(attestation)
if (!finishResponse.success) {
toast.error(
finishResponse.message || i18next.t('Failed to register Passkey')
)
return false
}
toast.success(i18next.t('Passkey registered successfully'))
await fetchStatus()
return true
} catch (error: unknown) {
if (error instanceof DOMException && error.name === 'NotAllowedError') {
toast.info(i18next.t('Passkey registration was cancelled'))
return false
}
// eslint-disable-next-line no-console
console.error('[Passkey] Registration error', error)
toast.error(
error instanceof Error
? error.message
: i18next.t('Failed to register Passkey')
)
return false
} finally {
setRegistering(false)
}
}, [supported, fetchStatus])
const remove = useCallback(async () => {
setRemoving(true)
try {
const res = await deletePasskey()
if (!res.success) {
toast.error(res.message || i18next.t('Failed to remove Passkey'))
return false
}
toast.success(i18next.t('Passkey removed successfully'))
await fetchStatus()
return true
} catch (error) {
// eslint-disable-next-line no-console
console.error('[Passkey] Removal error', error)
toast.error(i18next.t('Failed to remove Passkey'))
return false
} finally {
setRemoving(false)
}
}, [fetchStatus])
const enabled = useMemo(() => Boolean(status?.enabled), [status])
const lastUsed = useMemo(() => status?.last_used_at ?? null, [status])
return {
status,
loading,
registering,
removing,
supported,
enabled,
lastUsed,
fetchStatus,
register,
remove,
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './api'
export * from './types'
export * from './hooks/use-passkey-management'
+20
View File
@@ -0,0 +1,20 @@
export interface ApiResponse<T = unknown> {
success: boolean
message?: string
data?: T
}
export interface PasskeyStatus {
enabled: boolean
last_used_at?: string | null
backup_eligible?: boolean
backup_state?: boolean
[key: string]: unknown
}
export interface PasskeyOptionsPayload {
options?: unknown
publicKey?: unknown
response?: unknown
Response?: unknown
}
@@ -0,0 +1,182 @@
import { useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { CheckIcon, CopyIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { api } from '@/lib/api'
import { copyToClipboard } from '@/lib/copy-to-clipboard'
import { useCountdown } from '@/hooks/use-countdown'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { AuthLayout } from '../auth-layout'
export type ResetPasswordSearchParams = {
email?: string
token?: string
}
type ResetPasswordConfirmProps = ResetPasswordSearchParams
export function ResetPasswordConfirm({
email,
token,
}: ResetPasswordConfirmProps) {
const { t } = useTranslation()
const navigate = useNavigate()
const [newPassword, setNewPassword] = useState('')
const [loading, setLoading] = useState(false)
const [copied, setCopied] = useState(false)
const {
secondsLeft,
isActive,
start: startCountdown,
} = useCountdown({ initialSeconds: 30 })
const isValidResetLink = Boolean(email && token)
async function handleSubmit() {
if (!isValidResetLink || !email || !token) {
toast.error(t('Invalid reset link, please request a new password reset'))
return
}
startCountdown()
setLoading(true)
try {
const res = await api.post('/api/user/reset', { email, token }, {
skipBusinessError: true,
} as Record<string, unknown>)
if (res?.data?.success) {
const password = res.data.data
setNewPassword(password)
const copySuccess = await copyToClipboard(password)
if (copySuccess) {
toast.success(
t('Password reset and copied to clipboard: {{password}}', {
password,
})
)
} else {
toast.success(t('Password reset: {{password}}', { password }))
}
}
} catch {
// Errors handled by global interceptor
} finally {
setLoading(false)
}
}
async function handleCopy() {
if (!newPassword) return
const copySuccess = await copyToClipboard(newPassword)
if (copySuccess) {
setCopied(true)
toast.success(
t('Password copied to clipboard: {{password}}', {
password: newPassword,
})
)
setTimeout(() => setCopied(false), 2000)
}
}
return (
<AuthLayout>
<div className='w-full space-y-8'>
<div className='space-y-2'>
<h2 className='text-center text-2xl font-semibold tracking-tight sm:text-left'>
{t('Reset password')}
</h2>
<p className='text-muted-foreground text-left text-sm sm:text-base'>
{newPassword
? 'Your password has been reset successfully'
: 'Confirm the reset request to generate a new password.'}
</p>
</div>
<div className='space-y-4'>
{!isValidResetLink && (
<Alert variant='destructive'>
<AlertDescription>
{t('Invalid reset link, please request a new password reset.')}
</AlertDescription>
</Alert>
)}
<div className='space-y-2'>
<Label htmlFor='email'>{t('Email')}</Label>
<Input
id='email'
type='email'
value={email || ''}
disabled
placeholder={t('Waiting for email...')}
/>
</div>
{newPassword && (
<div className='space-y-2'>
<Label htmlFor='password'>{t('New password')}</Label>
<div className='flex gap-2'>
<Input
id='password'
value={newPassword}
disabled
className='font-mono'
/>
<Button
type='button'
size='icon'
variant='outline'
onClick={handleCopy}
>
{copied ? (
<CheckIcon className='h-4 w-4' />
) : (
<CopyIcon className='h-4 w-4' />
)}
</Button>
</div>
<p className='text-muted-foreground text-xs'>
{t('Password has been copied to clipboard')}
</p>
</div>
)}
<Button
className='w-full'
onClick={
newPassword
? () => navigate({ to: '/sign-in', replace: true })
: handleSubmit
}
disabled={
newPassword ? false : loading || isActive || !isValidResetLink
}
>
{newPassword
? 'Return to login'
: isActive
? `Retry (${secondsLeft}s)`
: 'Confirm reset password'}
</Button>
{!newPassword && (
<Button
variant='link'
className='w-full'
onClick={() => navigate({ to: '/sign-in', replace: true })}
>
{t('Back to login')}
</Button>
)}
</div>
</div>
</AuthLayout>
)
}
+143
View File
@@ -0,0 +1,143 @@
import { api, get2FAStatus } from '@/lib/api'
import {
buildAssertionResult,
prepareCredentialRequestOptions,
isPasskeySupported as detectPasskeySupport,
} from '@/lib/passkey'
import {
beginPasskeyVerification,
finishPasskeyVerification,
getPasskeyStatus,
} from '../passkey'
import type { VerificationMethod, VerificationMethods } from './types'
/**
* Fetch available verification methods for the current user.
*/
export async function checkVerificationMethods(): Promise<VerificationMethods> {
try {
const [twoFAResponse, passkeyResponse, passkeySupported] =
await Promise.all([
get2FAStatus(),
getPasskeyStatus(),
detectPasskeySupport(),
])
const has2FA =
Boolean(twoFAResponse?.success) && Boolean(twoFAResponse?.data?.enabled)
const hasPasskey =
Boolean(passkeyResponse?.success) &&
Boolean(passkeyResponse?.data?.enabled)
return {
has2FA,
hasPasskey,
passkeySupported,
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('[Secure Verification] Failed to check methods', error)
return {
has2FA: false,
hasPasskey: false,
passkeySupported: false,
}
}
}
/**
* Execute a verification flow based on the method type.
*/
export async function verify(
method: VerificationMethod,
code?: string
): Promise<void> {
switch (method) {
case '2fa':
return verifyTwoFA(code)
case 'passkey':
return verifyPasskey()
default:
throw new Error(`Unsupported verification method: ${method}`)
}
}
/**
* Perform 2FA verification flow.
*/
async function verifyTwoFA(code?: string | null): Promise<void> {
const trimmed = code?.trim()
if (!trimmed) {
throw new Error('Please enter the verification code or backup code')
}
const res = await api.post('/api/verify', {
method: '2fa',
code: trimmed,
})
if (!res.data?.success) {
throw new Error(res.data?.message || 'Verification failed')
}
}
/**
* Perform Passkey verification flow.
*/
async function verifyPasskey(): Promise<void> {
if (typeof navigator === 'undefined' || !navigator.credentials) {
throw new Error('Passkey verification is not supported in this environment')
}
try {
const beginResponse = await beginPasskeyVerification()
if (!beginResponse.success) {
throw new Error(beginResponse.message || 'Failed to start verification')
}
const publicKey = prepareCredentialRequestOptions(
beginResponse.data?.options ?? beginResponse.data
)
const credential = (await navigator.credentials.get({
publicKey,
})) as PublicKeyCredential | null
if (!credential) {
throw new Error('Passkey verification was cancelled')
}
const assertion = buildAssertionResult(credential)
if (!assertion) {
throw new Error('Unable to build Passkey assertion')
}
const finishResponse = await finishPasskeyVerification(assertion)
if (!finishResponse.success) {
throw new Error(finishResponse.message || 'Passkey verification failed')
}
const verifyResponse = await api.post('/api/verify', {
method: 'passkey',
})
if (!verifyResponse.data?.success) {
throw new Error(
verifyResponse.data?.message || 'Failed to complete verification'
)
}
} catch (error: unknown) {
if (error instanceof DOMException && error.name === 'NotAllowedError') {
throw new Error('Passkey verification was cancelled or timed out', {
cause: error,
})
}
if (error instanceof DOMException && error.name === 'InvalidStateError') {
throw new Error(
'Passkey verification is not available in the current state',
{ cause: error }
)
}
throw error
}
}
@@ -0,0 +1,194 @@
import { useMemo } from 'react'
import { ShieldCheck, KeyRound, Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import type {
SecureVerificationState,
VerificationMethod,
VerificationMethods,
} from '../types'
interface SecureVerificationDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
methods: VerificationMethods
state: SecureVerificationState
onVerify: (method: VerificationMethod, code?: string) => void | Promise<void>
onCancel: () => void
onCodeChange: (code: string) => void
onMethodChange: (method: VerificationMethod) => void
}
export function SecureVerificationDialog({
open,
onOpenChange,
methods,
state,
onVerify,
onCancel,
onCodeChange,
onMethodChange,
}: SecureVerificationDialogProps) {
const { t } = useTranslation()
const availableTabs: VerificationMethod[] = useMemo(() => {
const tabs: VerificationMethod[] = []
if (methods.has2FA) tabs.push('2fa')
if (methods.hasPasskey && methods.passkeySupported) tabs.push('passkey')
return tabs
}, [methods])
const activeMethod =
state.method ?? (availableTabs.length > 0 ? availableTabs[0] : null)
const title =
state.title ??
(availableTabs.length
? 'Additional verification required'
: 'Verification unavailable')
const description =
state.description ??
(availableTabs.length
? 'Confirm your identity before accessing this sensitive action.'
: 'Enable Two-factor Authentication or Passkey in your profile settings to continue.')
const handleVerify = () => {
if (!activeMethod) return
const payload = activeMethod === '2fa' ? state.code : undefined
onVerify(activeMethod, payload)
}
const verifyDisabled =
state.loading ||
(activeMethod === '2fa' && (!state.code.trim() || state.code.length < 6))
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className='top-[8vh] max-w-[calc(100%-1.5rem)] translate-y-0 gap-0 overflow-hidden border-none p-0 shadow-xl sm:top-1/2 sm:max-w-md sm:translate-y-[-50%] sm:rounded-xl'
showCloseButton={!state.loading}
>
<div className='bg-background flex max-h-[calc(100dvh-2rem)] flex-col'>
<DialogHeader className='border-b px-6 py-5 text-left'>
<DialogTitle className='flex items-center gap-2 text-lg font-semibold'>
<ShieldCheck className='text-primary h-5 w-5' />
{title}
</DialogTitle>
<DialogDescription className='text-left'>
{description}
</DialogDescription>
</DialogHeader>
<div className='flex-1 overflow-y-auto px-6 py-5'>
{availableTabs.length === 0 ? (
<div className='grid place-items-center gap-4 text-center'>
<div className='bg-muted flex h-16 w-16 items-center justify-center rounded-full'>
<ShieldCheck className='text-muted-foreground h-8 w-8' />
</div>
<p className='text-muted-foreground text-sm'>
{t(
'Enable Two-factor Authentication or Passkey in your profile to unlock sensitive operations.'
)}
</p>
</div>
) : (
<Tabs
value={activeMethod ?? availableTabs[0]}
onValueChange={(value) =>
onMethodChange(value as VerificationMethod)
}
className='gap-4'
>
<TabsList>
{methods.has2FA && (
<TabsTrigger value='2fa'>
{t('Authenticator code')}
</TabsTrigger>
)}
{methods.hasPasskey && methods.passkeySupported && (
<TabsTrigger value='passkey'>{t('Passkey')}</TabsTrigger>
)}
</TabsList>
<TabsContent value='2fa' className='space-y-3'>
<p className='text-muted-foreground text-sm'>
{t(
'Enter the 6-digit Time-based One-Time Password or 8-character backup code from your authenticator app.'
)}
</p>
<Input
inputMode='numeric'
maxLength={8}
value={state.code}
onChange={(event) => onCodeChange(event.target.value)}
placeholder={t('Enter verification code')}
disabled={state.loading}
autoFocus={activeMethod === '2fa'}
onKeyDown={(event) => {
if (event.key === 'Enter' && !verifyDisabled) {
event.preventDefault()
handleVerify()
}
}}
/>
</TabsContent>
<TabsContent value='passkey' className='space-y-4'>
<div className='bg-muted/50 flex items-center justify-center rounded-lg p-4'>
<div className='text-muted-foreground flex items-center gap-3'>
<KeyRound className='text-primary h-6 w-6' />
<div className='text-left text-sm'>
<p className='text-foreground font-medium'>
{t('Use your Passkey')}
</p>
<p>
{t(
'We will prompt your device to confirm using biometrics or your hardware key.'
)}
</p>
</div>
</div>
</div>
{!methods.passkeySupported && (
<p className='text-destructive text-sm'>
{t('This device does not support Passkey verification.')}
</p>
)}
</TabsContent>
</Tabs>
)}
</div>
<DialogFooter className='bg-muted/30 border-t px-6 py-4 sm:flex-row sm:justify-end'>
<Button
type='button'
variant='outline'
disabled={state.loading}
onClick={onCancel}
>
{t('Cancel')}
</Button>
<Button
type='button'
onClick={handleVerify}
disabled={availableTabs.length === 0 || verifyDisabled}
>
{state.loading && <Loader2 className='h-4 w-4 animate-spin' />}
{t('Verify')}
</Button>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,220 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import i18next from 'i18next'
import { toast } from 'sonner'
import {
extractVerificationInfo,
isVerificationRequiredError,
} from '@/lib/secure-verification'
import { checkVerificationMethods, verify } from '../api'
import type {
SecureVerificationState,
StartVerificationOptions,
UseSecureVerificationOptions,
VerificationMethod,
VerificationMethods,
} from '../types'
type ApiCall = (() => Promise<unknown>) | null
interface InternalState extends SecureVerificationState {
apiCall: ApiCall
}
const defaultMethods: VerificationMethods = {
has2FA: false,
hasPasskey: false,
passkeySupported: false,
}
const initialState: InternalState = {
method: null,
loading: false,
code: '',
title: undefined,
description: undefined,
apiCall: null,
}
export function useSecureVerification(
options: UseSecureVerificationOptions = {}
) {
const { onSuccess, onError, successMessage, autoReset = true } = options
const [methods, setMethods] = useState<VerificationMethods>(defaultMethods)
const [state, setState] = useState<InternalState>(initialState)
const [open, setOpen] = useState(false)
const fetchVerificationMethods = useCallback(async () => {
const result = await checkVerificationMethods()
setMethods(result)
return result
}, [])
useEffect(() => {
fetchVerificationMethods()
}, [fetchVerificationMethods])
const reset = useCallback(() => {
setState(initialState)
setOpen(false)
}, [])
const startVerification = useCallback(
async (
apiCall: () => Promise<unknown>,
config: StartVerificationOptions = {}
) => {
const { preferredMethod, title, description } = config
const availableMethods = await fetchVerificationMethods()
if (!availableMethods.has2FA && !availableMethods.hasPasskey) {
toast.error(
i18next.t(
'Please enable Two-factor Authentication or Passkey before proceeding'
)
)
onError?.(
new Error(
'No verification methods available. Enable 2FA or Passkey to continue.'
)
)
return false
}
let defaultMethod: VerificationMethod | null = preferredMethod ?? null
if (!defaultMethod) {
if (availableMethods.hasPasskey && availableMethods.passkeySupported) {
defaultMethod = 'passkey'
} else if (availableMethods.has2FA) {
defaultMethod = '2fa'
}
}
setState((prev) => ({
...prev,
apiCall,
method: defaultMethod,
title,
description,
}))
setOpen(true)
return true
},
[fetchVerificationMethods, onError]
)
const executeVerification = useCallback(
async (method?: VerificationMethod, code?: string) => {
if (!state.apiCall) {
toast.error(i18next.t('Verification is not configured properly'))
return
}
const actualMethod = method ?? state.method
if (!actualMethod) {
toast.error(i18next.t('Select a verification method first'))
return
}
setState((prev) => ({ ...prev, loading: true }))
try {
await verify(actualMethod, code ?? state.code)
const result = await state.apiCall()
if (successMessage) {
toast.success(successMessage)
}
onSuccess?.(result, actualMethod)
if (autoReset) {
reset()
}
return result
} catch (error) {
const message =
error instanceof Error
? error.message
: i18next.t('Verification failed')
toast.error(message)
onError?.(error)
throw error
} finally {
setState((prev) => ({ ...prev, loading: false }))
}
},
[state, successMessage, onSuccess, onError, autoReset, reset]
)
const setCode = useCallback((code: string) => {
setState((prev) => ({ ...prev, code }))
}, [])
const switchMethod = useCallback((method: VerificationMethod) => {
setState((prev) => ({ ...prev, method, code: '' }))
}, [])
const cancel = useCallback(() => {
reset()
}, [reset])
const withVerification = useCallback(
async (
apiCall: () => Promise<unknown>,
config: StartVerificationOptions = {}
) => {
try {
return await apiCall()
} catch (error) {
if (isVerificationRequiredError(error)) {
const info = extractVerificationInfo(error)
toast.info(info.message)
await startVerification(apiCall, config)
return null
}
throw error
}
},
[startVerification]
)
const canUseMethod = useCallback(
(method: VerificationMethod) => {
if (method === '2fa') return methods.has2FA
if (method === 'passkey') {
return methods.hasPasskey && methods.passkeySupported
}
return false
},
[methods]
)
const recommendedMethod = useMemo<VerificationMethod | null>(() => {
if (methods.hasPasskey && methods.passkeySupported) return 'passkey'
if (methods.has2FA) return '2fa'
return null
}, [methods])
return {
open,
setOpen,
methods,
state,
startVerification,
executeVerification,
cancel,
reset,
setCode,
switchMethod,
withVerification,
fetchVerificationMethods,
canUseMethod,
recommendedMethod,
hasAnyMethod: methods.has2FA || methods.hasPasskey,
isLoading: state.loading,
currentMethod: state.method,
code: state.code,
}
}
@@ -0,0 +1,4 @@
export * from './api'
export * from './types'
export * from './hooks/use-secure-verification'
export * from './components/secure-verification-dialog'
@@ -0,0 +1,28 @@
export type VerificationMethod = '2fa' | 'passkey'
export interface VerificationMethods {
has2FA: boolean
hasPasskey: boolean
passkeySupported: boolean
}
export interface SecureVerificationState {
method: VerificationMethod | null
loading: boolean
code: string
title?: string
description?: string
}
export interface UseSecureVerificationOptions {
onSuccess?: (result: unknown, method: VerificationMethod) => void
onError?: (error: unknown) => void
successMessage?: string
autoReset?: boolean
}
export interface StartVerificationOptions {
preferredMethod?: VerificationMethod
title?: string
description?: string
}
@@ -0,0 +1,435 @@
import { useEffect, useMemo, useState } from 'react'
import type { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { Link } from '@tanstack/react-router'
import { Loader2, LogIn, KeyRound } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
buildAssertionResult,
prepareCredentialRequestOptions,
isPasskeySupported as detectPasskeySupport,
} from '@/lib/passkey'
import { cn } from '@/lib/utils'
import { useStatus } from '@/hooks/use-status'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { PasswordInput } from '@/components/password-input'
import { Turnstile } from '@/components/turnstile'
import { login, wechatLoginByCode } from '@/features/auth/api'
import { LegalConsent } from '@/features/auth/components/legal-consent'
import { OAuthProviders } from '@/features/auth/components/oauth-providers'
import { loginFormSchema } from '@/features/auth/constants'
import { useAuthRedirect } from '@/features/auth/hooks/use-auth-redirect'
import { useTurnstile } from '@/features/auth/hooks/use-turnstile'
import { beginPasskeyLogin, finishPasskeyLogin } from '@/features/auth/passkey'
import type { AuthFormProps } from '@/features/auth/types'
export function UserAuthForm({
className,
redirectTo,
...props
}: AuthFormProps) {
const { t } = useTranslation()
const [isLoading, setIsLoading] = useState(false)
const [wechatCode, setWeChatCode] = useState('')
const [agreedToLegal, setAgreedToLegal] = useState(false)
const [passkeySupported, setPasskeySupported] = useState(false)
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false)
const [isWeChatDialogOpen, setIsWeChatDialogOpen] = useState(false)
const [isWeChatSubmitting, setIsWeChatSubmitting] = useState(false)
const legalConsentErrorMessage = t('Please agree to the legal terms first')
const loginFailedMessage = t('Login failed')
const { status } = useStatus()
const passkeyLoginEnabled = Boolean(
status?.passkey_login ?? status?.data?.passkey_login
)
const {
isTurnstileEnabled,
turnstileSiteKey,
turnstileToken,
setTurnstileToken,
validateTurnstile,
} = useTurnstile()
const { handleLoginSuccess, redirectTo2FA } = useAuthRedirect()
const hasUserAgreement = Boolean(status?.user_agreement_enabled)
const hasPrivacyPolicy = Boolean(status?.privacy_policy_enabled)
const requiresLegalConsent = hasUserAgreement || hasPrivacyPolicy
const passkeyButtonDisabled =
isPasskeyLoading ||
!passkeySupported ||
(requiresLegalConsent && !agreedToLegal)
const hasWeChatLogin = Boolean(status?.wechat_login)
useEffect(() => {
if (requiresLegalConsent) {
setAgreedToLegal(false)
} else {
setAgreedToLegal(true)
}
}, [requiresLegalConsent])
useEffect(() => {
detectPasskeySupport()
.then(setPasskeySupported)
.catch(() => setPasskeySupported(false))
}, [])
const form = useForm<z.infer<typeof loginFormSchema>>({
resolver: zodResolver(loginFormSchema),
defaultValues: {
username: '',
password: '',
},
})
const wechatQrCodeUrl = useMemo(() => {
return (
status?.wechat_qrcode ||
status?.wechat_qr_code ||
status?.wechat_qrcode_image_url ||
status?.wechat_qr_code_image_url ||
status?.wechat_account_qrcode_image_url ||
status?.WeChatAccountQRCodeImageURL ||
status?.data?.wechat_qrcode ||
status?.data?.WeChatAccountQRCodeImageURL ||
''
)
}, [status])
async function onSubmit(data: z.infer<typeof loginFormSchema>) {
if (requiresLegalConsent && !agreedToLegal) {
toast.error(legalConsentErrorMessage)
return
}
if (!validateTurnstile()) return
setIsLoading(true)
try {
const res = await login({
username: data.username,
password: data.password,
turnstile: turnstileToken,
})
if (res.success) {
if (res.data?.require_2fa) {
redirectTo2FA()
return
}
await handleLoginSuccess(res.data as { id?: number } | null, redirectTo)
toast.success(t('Welcome back!'))
}
} catch (_error) {
// Errors are handled by global interceptor
} finally {
setIsLoading(false)
}
}
const handleOpenWeChatDialog = () => {
if (requiresLegalConsent && !agreedToLegal) {
toast.error(legalConsentErrorMessage)
return
}
setIsWeChatDialogOpen(true)
}
const handleWeChatDialogChange = (open: boolean) => {
setIsWeChatDialogOpen(open)
if (!open) {
setWeChatCode('')
setIsWeChatSubmitting(false)
}
}
async function handleWeChatLogin() {
if (!wechatCode.trim()) {
toast.error(t('Please enter the verification code'))
return
}
setIsWeChatSubmitting(true)
try {
const res = await wechatLoginByCode(wechatCode)
if (res?.success) {
await handleLoginSuccess(res.data as { id?: number } | null, redirectTo)
toast.success(t('Signed in via WeChat'))
handleWeChatDialogChange(false)
} else {
toast.error(res?.message || loginFailedMessage)
}
} catch (_error) {
toast.error(loginFailedMessage)
} finally {
setIsWeChatSubmitting(false)
}
}
async function handlePasskeyLogin() {
if (requiresLegalConsent && !agreedToLegal) {
toast.error(legalConsentErrorMessage)
return
}
if (!passkeySupported) {
toast.error(t('Passkey is not supported on this device'))
return
}
if (!navigator?.credentials) {
toast.error(t('Passkey is not available in this browser'))
return
}
setIsPasskeyLoading(true)
try {
const begin = await beginPasskeyLogin()
if (!begin.success) {
throw new Error(begin.message || t('Failed to start Passkey login'))
}
const publicKey = prepareCredentialRequestOptions(
begin.data?.options ?? begin.data
)
const credential = (await navigator.credentials.get({
publicKey,
})) as PublicKeyCredential | null
if (!credential) {
toast.info(t('Passkey login was cancelled'))
return
}
const assertion = buildAssertionResult(credential)
if (!assertion) {
throw new Error(t('Invalid Passkey response'))
}
const finish = await finishPasskeyLogin(assertion)
if (!finish.success) {
throw new Error(finish.message || t('Failed to complete Passkey login'))
}
if (!finish.data) {
throw new Error(t('Missing user data from Passkey login response'))
}
await handleLoginSuccess(
finish.data as { id?: number } | null,
redirectTo
)
toast.success(t('Signed in with Passkey'))
} catch (error: unknown) {
if (error instanceof DOMException && error.name === 'NotAllowedError') {
toast.info(t('Passkey login was cancelled or timed out'))
} else if (error instanceof Error) {
toast.error(error.message)
} else {
toast.error(t('Passkey login failed'))
}
} finally {
setIsPasskeyLoading(false)
}
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className={cn('grid gap-4', className)}
{...props}
>
{/* Username Field */}
<FormField
control={form.control}
name='username'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Username or Email')}</FormLabel>
<FormControl>
<Input
placeholder={t('Enter your username or email')}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Password Field */}
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem className='relative'>
<FormLabel>{t('Password')}</FormLabel>
<FormControl>
<PasswordInput placeholder={t('Enter password')} {...field} />
</FormControl>
<FormMessage />
<Link
to='/forgot-password'
className='text-muted-foreground absolute end-0 -top-0.5 text-sm font-medium hover:opacity-75'
>
{t('Forgot password?')}
</Link>
</FormItem>
)}
/>
{/* Submit Button */}
<Button
className='mt-2 w-full justify-center gap-2'
disabled={isLoading || (requiresLegalConsent && !agreedToLegal)}
>
{isLoading ? <Loader2 className='animate-spin' /> : <LogIn />}
{t('Sign in')}
</Button>
{/* Turnstile */}
{isTurnstileEnabled && (
<div className='mt-2'>
<Turnstile
siteKey={turnstileSiteKey}
onVerify={setTurnstileToken}
/>
</div>
)}
<LegalConsent
status={status}
checked={agreedToLegal}
onCheckedChange={setAgreedToLegal}
className='mt-1'
/>
{passkeyLoginEnabled && (
<div className='mt-2 space-y-1'>
<Button
type='button'
variant='outline'
disabled={passkeyButtonDisabled}
onClick={handlePasskeyLogin}
className='h-11 w-full justify-center gap-2 rounded-lg'
>
{isPasskeyLoading ? (
<Loader2 className='h-4 w-4 animate-spin' />
) : (
<KeyRound className='h-4 w-4' />
)}
{t('Sign in with Passkey')}
</Button>
{!passkeySupported && (
<p className='text-muted-foreground text-xs'>
{t('Passkey is not supported on this device.')}
</p>
)}
</div>
)}
{/* OAuth Providers */}
<OAuthProviders
status={status}
disabled={isLoading || (requiresLegalConsent && !agreedToLegal)}
onWeChatLogin={hasWeChatLogin ? handleOpenWeChatDialog : undefined}
isWeChatLoading={isWeChatSubmitting}
/>
</form>
{hasWeChatLogin && (
<Dialog
open={isWeChatDialogOpen}
onOpenChange={handleWeChatDialogChange}
>
<DialogContent className='max-w-sm'>
<DialogHeader className='text-left'>
<DialogTitle>{t('WeChat sign in')}</DialogTitle>
<DialogDescription>
{t(
'Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.'
)}
</DialogDescription>
</DialogHeader>
{wechatQrCodeUrl ? (
<div className='flex justify-center'>
<img
src={wechatQrCodeUrl}
alt={t('WeChat login QR code')}
className='h-40 w-40 rounded-md border object-contain'
/>
</div>
) : (
<p className='text-muted-foreground text-sm'>
{t('QR code is not configured. Please contact support.')}
</p>
)}
<div className='grid gap-2'>
<Label htmlFor='wechat-code'>{t('Verification code')}</Label>
<Input
id='wechat-code'
placeholder={t('Enter the verification code')}
value={wechatCode}
onChange={(event) => setWeChatCode(event.target.value)}
autoComplete='one-time-code'
/>
</div>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => handleWeChatDialogChange(false)}
disabled={isWeChatSubmitting}
>
{t('Cancel')}
</Button>
<Button
type='button'
onClick={handleWeChatLogin}
disabled={
isWeChatSubmitting ||
!wechatCode.trim() ||
(requiresLegalConsent && !agreedToLegal)
}
className='gap-2'
>
{isWeChatSubmitting ? (
<Loader2 className='h-4 w-4 animate-spin' />
) : null}
{t('Confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</Form>
)
}
+44
View File
@@ -0,0 +1,44 @@
import { Link, useSearch } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { useStatus } from '@/hooks/use-status'
import { AuthLayout } from '../auth-layout'
import { TermsFooter } from '../components/terms-footer'
import { UserAuthForm } from './components/user-auth-form'
export function SignIn() {
const { t } = useTranslation()
const { redirect } = useSearch({ from: '/(auth)/sign-in' })
const { status } = useStatus()
return (
<AuthLayout>
<div className='w-full space-y-8'>
<div className='space-y-2'>
<h2 className='text-center text-2xl font-semibold tracking-tight sm:text-left'>
{t('Sign in')}
</h2>
{!status?.self_use_mode_enabled && (
<p className='text-muted-foreground text-left text-sm sm:text-base'>
{t("Don't have an account?")}{' '}
<Link
to='/sign-up'
className='hover:text-primary font-medium underline underline-offset-4'
>
{t('Sign up')}
</Link>
.
</p>
)}
</div>
<UserAuthForm redirectTo={redirect} />
<TermsFooter
variant='sign-in'
status={status}
className='text-center'
/>
</div>
</AuthLayout>
)
}
@@ -0,0 +1,412 @@
import { useEffect, useMemo, useState } from 'react'
import type { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { useStatus } from '@/hooks/use-status'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { PasswordInput } from '@/components/password-input'
import { Turnstile } from '@/components/turnstile'
import { register, wechatLoginByCode } from '@/features/auth/api'
import { LegalConsent } from '@/features/auth/components/legal-consent'
import { OAuthProviders } from '@/features/auth/components/oauth-providers'
import { registerFormSchema } from '@/features/auth/constants'
import { useAuthRedirect } from '@/features/auth/hooks/use-auth-redirect'
import { useEmailVerification } from '@/features/auth/hooks/use-email-verification'
import { useTurnstile } from '@/features/auth/hooks/use-turnstile'
import { getAffiliateCode } from '@/features/auth/lib/storage'
export function SignUpForm({
className,
...props
}: React.HTMLAttributes<HTMLFormElement>) {
const { t } = useTranslation()
const [isLoading, setIsLoading] = useState(false)
const [verificationCode, setVerificationCode] = useState('')
const [agreedToLegal, setAgreedToLegal] = useState(false)
const [wechatCode, setWeChatCode] = useState('')
const [isWeChatDialogOpen, setIsWeChatDialogOpen] = useState(false)
const [isWeChatSubmitting, setIsWeChatSubmitting] = useState(false)
const legalConsentErrorMessage = t('Please agree to the legal terms first')
const { status } = useStatus()
const {
isTurnstileEnabled,
turnstileSiteKey,
turnstileToken,
setTurnstileToken,
validateTurnstile,
} = useTurnstile()
const { redirectToLogin, handleLoginSuccess } = useAuthRedirect()
const {
isSending: isSendingCode,
secondsLeft,
isActive,
sendCode,
} = useEmailVerification({
turnstileToken,
validateTurnstile,
})
const form = useForm<z.infer<typeof registerFormSchema>>({
resolver: zodResolver(registerFormSchema),
defaultValues: {
username: '',
email: '',
password: '',
confirmPassword: '',
},
})
const emailValue = form.watch('email')
const emailVerificationRequired = !!status?.email_verification
const hasUserAgreement = Boolean(status?.user_agreement_enabled)
const hasPrivacyPolicy = Boolean(status?.privacy_policy_enabled)
const requiresLegalConsent = hasUserAgreement || hasPrivacyPolicy
const oauthRegisterEnabled =
status?.oauth_register_enabled ??
status?.data?.oauth_register_enabled ??
true
const hasWeChatLogin = Boolean(status?.wechat_login)
const wechatQrCodeUrl = useMemo(() => {
return (
status?.wechat_qrcode ||
status?.wechat_qr_code ||
status?.wechat_qrcode_image_url ||
status?.wechat_qr_code_image_url ||
status?.wechat_account_qrcode_image_url ||
status?.WeChatAccountQRCodeImageURL ||
status?.data?.wechat_qrcode ||
status?.data?.WeChatAccountQRCodeImageURL ||
''
)
}, [status])
useEffect(() => {
if (requiresLegalConsent) {
setAgreedToLegal(false)
} else {
setAgreedToLegal(true)
}
}, [requiresLegalConsent])
async function onSubmit(data: z.infer<typeof registerFormSchema>) {
if (requiresLegalConsent && !agreedToLegal) {
toast.error(legalConsentErrorMessage)
return
}
// Validate email verification if required
if (emailVerificationRequired) {
if (!data.email) {
toast.error(t('Please enter your email'))
return
}
if (!verificationCode) {
toast.error(t('Please enter the verification code'))
return
}
}
setIsLoading(true)
try {
const res = await register({
username: data.username,
password: data.password,
email: data.email || undefined,
verification_code: verificationCode || undefined,
aff: getAffiliateCode(),
turnstile: turnstileToken,
})
if (res?.success) {
toast.success(t('Account created! Please sign in'))
redirectToLogin()
}
} catch (_error) {
// Errors are handled by global interceptor
} finally {
setIsLoading(false)
}
}
async function handleSendVerificationCode() {
await sendCode(emailValue || '')
}
const handleOpenWeChatDialog = () => {
if (requiresLegalConsent && !agreedToLegal) {
toast.error(legalConsentErrorMessage)
return
}
setIsWeChatDialogOpen(true)
}
const handleWeChatDialogChange = (open: boolean) => {
setIsWeChatDialogOpen(open)
if (!open) {
setWeChatCode('')
setIsWeChatSubmitting(false)
}
}
async function handleWeChatLogin() {
if (!wechatCode.trim()) {
toast.error(t('Please enter the verification code'))
return
}
setIsWeChatSubmitting(true)
try {
const res = await wechatLoginByCode(wechatCode)
if (res?.success) {
await handleLoginSuccess(res.data as { id?: number } | null)
toast.success(t('Signed in via WeChat'))
handleWeChatDialogChange(false)
} else {
toast.error(res?.message || t('Login failed'))
}
} catch (_error) {
toast.error(t('Login failed'))
} finally {
setIsWeChatSubmitting(false)
}
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className={cn('grid gap-4', className)}
{...props}
>
{/* Username Field */}
<FormField
control={form.control}
name='username'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Username')}</FormLabel>
<FormControl>
<Input placeholder={t('Enter your username')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Password Field */}
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Password')}</FormLabel>
<FormControl>
<PasswordInput
placeholder={t('Enter password (8-20 characters)')}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Confirm Password Field */}
<FormField
control={form.control}
name='confirmPassword'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Confirm password')}</FormLabel>
<FormControl>
<PasswordInput placeholder={t('Confirm password')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Email Verification Section */}
{emailVerificationRequired && (
<>
{/* Email Field */}
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('Email (required for verification)')}
</FormLabel>
<FormControl>
<Input
placeholder={t('name@example.com')}
type='email'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Verification Code Field */}
<div className='flex items-end gap-2'>
<div className='flex-1'>
<Input
placeholder={t('Verification code')}
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
/>
</div>
<Button
variant='outline'
type='button'
disabled={isLoading || isSendingCode || isActive || !emailValue}
onClick={handleSendVerificationCode}
>
{isActive ? (
t('Resend ({{seconds}}s)', { seconds: secondsLeft })
) : isSendingCode ? (
<Loader2 className='h-4 w-4 animate-spin' />
) : (
t('Send code')
)}
</Button>
</div>
{/* Turnstile */}
{isTurnstileEnabled && (
<div className='mt-2'>
<Turnstile
siteKey={turnstileSiteKey}
onVerify={setTurnstileToken}
/>
</div>
)}
</>
)}
<LegalConsent
status={status}
checked={agreedToLegal}
onCheckedChange={setAgreedToLegal}
className='mt-1'
/>
{/* Submit Button */}
<Button
className='mt-2 w-full justify-center gap-2'
disabled={isLoading || (requiresLegalConsent && !agreedToLegal)}
>
{isLoading ? <Loader2 className='h-4 w-4 animate-spin' /> : null}
{t('Create account')}
</Button>
{oauthRegisterEnabled && (
<OAuthProviders
status={status}
disabled={isLoading || (requiresLegalConsent && !agreedToLegal)}
onWeChatLogin={hasWeChatLogin ? handleOpenWeChatDialog : undefined}
isWeChatLoading={isWeChatSubmitting}
className='pt-2'
/>
)}
</form>
{hasWeChatLogin && (
<Dialog
open={isWeChatDialogOpen}
onOpenChange={handleWeChatDialogChange}
>
<DialogContent className='max-w-sm'>
<DialogHeader className='text-left'>
<DialogTitle>{t('WeChat sign in')}</DialogTitle>
<DialogDescription>
{t(
'Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.'
)}
</DialogDescription>
</DialogHeader>
{wechatQrCodeUrl ? (
<div className='flex justify-center'>
<img
src={wechatQrCodeUrl}
alt={t('WeChat login QR code')}
className='h-40 w-40 rounded-md border object-contain'
/>
</div>
) : (
<p className='text-muted-foreground text-sm'>
{t('QR code is not configured. Please contact support.')}
</p>
)}
<div className='grid gap-2'>
<Label htmlFor='wechat-code'>{t('Verification code')}</Label>
<Input
id='wechat-code'
placeholder={t('Enter the verification code')}
value={wechatCode}
onChange={(event) => setWeChatCode(event.target.value)}
autoComplete='one-time-code'
/>
</div>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => handleWeChatDialogChange(false)}
disabled={isWeChatSubmitting}
>
{t('Cancel')}
</Button>
<Button
type='button'
onClick={handleWeChatLogin}
disabled={
isWeChatSubmitting ||
!wechatCode.trim() ||
(requiresLegalConsent && !agreedToLegal)
}
className='gap-2'
>
{isWeChatSubmitting ? (
<Loader2 className='h-4 w-4 animate-spin' />
) : null}
{t('Confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</Form>
)
}
+41
View File
@@ -0,0 +1,41 @@
import { Link } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { useStatus } from '@/hooks/use-status'
import { AuthLayout } from '../auth-layout'
import { TermsFooter } from '../components/terms-footer'
import { SignUpForm } from './components/sign-up-form'
export function SignUp() {
const { t } = useTranslation()
const { status } = useStatus()
return (
<AuthLayout>
<div className='w-full space-y-8'>
<div className='space-y-2'>
<h2 className='text-center text-2xl font-semibold tracking-tight sm:text-left'>
{t('Create an account')}
</h2>
<p className='text-muted-foreground text-left text-sm sm:text-base'>
{t('Already have an account?')}{' '}
<Link
to='/sign-in'
className='hover:text-primary font-medium underline underline-offset-4'
>
{t('Sign in')}
</Link>
.
</p>
</div>
<SignUpForm />
<TermsFooter
variant='sign-up'
status={status}
className='text-center'
/>
</div>
</AuthLayout>
)
}
+186
View File
@@ -0,0 +1,186 @@
import type { User } from '@/features/users/types'
// ============================================================================
// API Payloads
// ============================================================================
export interface LoginPayload {
username: string
password: string
turnstile?: string
}
export interface TwoFAPayload {
code: string
}
export interface RegisterPayload {
username: string
password: string
email?: string
verification_code?: string
aff?: string
turnstile?: string
}
export interface PasswordResetPayload {
email: string
turnstile?: string
}
export interface EmailVerificationPayload {
email: string
turnstile?: string
}
export interface BindEmailPayload {
email: string
code: string
}
// ============================================================================
// API Responses
// ============================================================================
export interface LoginResponse {
success: boolean
message: string
data?: {
require_2fa?: boolean
id?: number
}
}
export interface Login2FAResponse {
success: boolean
message: string
data?: User
}
export interface ApiResponse {
success: boolean
message: string
data?: unknown
}
// ============================================================================
// System Status
// ============================================================================
export interface SystemStatus {
success?: boolean
message?: string
data?: {
version?: string
system_name?: string
logo?: string
github_oauth?: boolean
github_client_id?: string
discord_oauth?: boolean
discord_client_id?: string
oidc_enabled?: boolean
oidc_authorization_endpoint?: string
oidc_client_id?: string
linuxdo_oauth?: boolean
linuxdo_client_id?: string
telegram_oauth?: boolean
passkey_login?: boolean
wechat_login?: boolean
wechat_qrcode?: string
wechat_qr_code?: string
wechat_qrcode_image_url?: string
wechat_qr_code_image_url?: string
wechat_account_qrcode_image_url?: string
WeChatAccountQRCodeImageURL?: string
turnstile_check?: boolean
turnstile_site_key?: string
email_verification?: boolean
self_use_mode_enabled?: boolean
display_in_currency?: boolean
display_token_stat_enabled?: boolean
quota_per_unit?: number
quota_display_type?: string
usd_exchange_rate?: number
custom_currency_symbol?: string
custom_currency_exchange_rate?: number
demo_site_enabled?: boolean
user_agreement_enabled?: boolean
privacy_policy_enabled?: boolean
oauth_register_enabled?: boolean
register_enabled?: boolean
password_register_enabled?: boolean
custom_oauth_providers?: CustomOAuthProviderInfo[]
[key: string]: unknown
}
// Allow direct access to common properties
version?: string
system_name?: string
logo?: string
github_oauth?: boolean
github_client_id?: string
discord_oauth?: boolean
discord_client_id?: string
oidc_enabled?: boolean
oidc_authorization_endpoint?: string
oidc_client_id?: string
linuxdo_oauth?: boolean
linuxdo_client_id?: string
telegram_oauth?: boolean
passkey_login?: boolean
wechat_login?: boolean
wechat_qrcode?: string
wechat_qr_code?: string
wechat_qrcode_image_url?: string
wechat_qr_code_image_url?: string
wechat_account_qrcode_image_url?: string
WeChatAccountQRCodeImageURL?: string
turnstile_check?: boolean
turnstile_site_key?: string
email_verification?: boolean
self_use_mode_enabled?: boolean
display_in_currency?: boolean
display_token_stat_enabled?: boolean
quota_per_unit?: number
quota_display_type?: string
usd_exchange_rate?: number
custom_currency_symbol?: string
custom_currency_exchange_rate?: number
demo_site_enabled?: boolean
user_agreement_enabled?: boolean
privacy_policy_enabled?: boolean
oauth_register_enabled?: boolean
register_enabled?: boolean
password_register_enabled?: boolean
custom_oauth_providers?: CustomOAuthProviderInfo[]
[key: string]: unknown
}
// ============================================================================
// OAuth
// ============================================================================
export interface OAuthProvider {
name: string
type: 'github' | 'discord' | 'oidc' | 'linuxdo' | 'telegram' | 'wechat'
enabled: boolean
clientId?: string
authEndpoint?: string
}
export interface CustomOAuthProviderInfo {
id: number
name: string
slug: string
icon: string
client_id: string
authorization_endpoint: string
scopes: string
}
// ============================================================================
// Form Props
// ============================================================================
export interface AuthFormProps extends React.HTMLAttributes<HTMLFormElement> {
redirectTo?: string
}