diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bea19d4..af35b61 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1978,7 +1978,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "maplelink" -version = "0.3.0" +version = "0.3.2" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d8a6e89..80d4e21 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -44,6 +44,7 @@ windows-sys = { version = "0.61", features = [ "Win32_Graphics_Dwm", "Win32_Graphics_Gdi", "Win32_Security_Cryptography", + "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Threading", "Win32_UI_Input_KeyboardAndMouse", diff --git a/src-tauri/src/commands/account.rs b/src-tauri/src/commands/account.rs index 59af212..19b1bf9 100644 --- a/src-tauri/src/commands/account.rs +++ b/src-tauri/src/commands/account.rs @@ -89,7 +89,27 @@ pub async fn get_game_accounts( } let accounts = ss.game_accounts.read().await; - let dtos = accounts.iter().map(GameAccountDto::from).collect(); + let overrides = state.display_overrides.read().await; + let mut dtos: Vec = accounts + .iter() + .map(|a| { + let mut dto = GameAccountDto::from(a); + if let Some(name) = overrides.names.get(&dto.id) { + dto.display_name = name.clone(); + } + dto + }) + .collect(); + // Apply custom sort order + if !overrides.order.is_empty() { + dtos.sort_by_key(|d| { + overrides + .order + .iter() + .position(|id| id == &d.id) + .unwrap_or(usize::MAX) + }); + } Ok(dtos) } @@ -152,7 +172,26 @@ pub async fn refresh_accounts( .await .map_err(login_err_to_dto)?; - let dtos: Vec = accounts.iter().map(GameAccountDto::from).collect(); + let overrides = state.display_overrides.read().await; + let mut dtos: Vec = accounts + .iter() + .map(|a| { + let mut dto = GameAccountDto::from(a); + if let Some(name) = overrides.names.get(&dto.id) { + dto.display_name = name.clone(); + } + dto + }) + .collect(); + if !overrides.order.is_empty() { + dtos.sort_by_key(|d| { + overrides + .order + .iter() + .position(|id| id == &d.id) + .unwrap_or(usize::MAX) + }); + } drop(session_guard); *ss.game_accounts.write().await = accounts; @@ -295,12 +334,18 @@ pub async fn change_account_display_name( let session_guard = ss.session.read().await; let _session = auth::require_valid_session(&session_guard).map_err(to_dto)?; + let region = state.config.read().await.region.clone(); let game_code = format!("{}_{}", DEFAULT_SERVICE_CODE, DEFAULT_SERVICE_REGION); - let success = - beanfun_service::change_display_name(&ss.http_client, &game_code, &account_id, &new_name) - .await - .map_err(login_err_to_dto)?; + let success = beanfun_service::change_display_name( + &ss.http_client, + ®ion, + &game_code, + &account_id, + &new_name, + ) + .await + .map_err(login_err_to_dto)?; if success { tracing::info!(account_id = %account_id, new_name = %new_name, "display name changed"); @@ -311,6 +356,60 @@ pub async fn change_account_display_name( Ok(success) } +/// Save a local display name override (persisted to display_overrides.json). +/// +/// Used when the user renames an account locally (HK region, or TW without sync). +#[tauri::command] +pub async fn set_display_override( + account_id: String, + display_name: String, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + let mut overrides = state.display_overrides.write().await; + if display_name.is_empty() { + overrides.names.remove(&account_id); + } else { + overrides + .names + .insert(account_id.clone(), display_name.clone()); + } + if let Err(e) = + crate::services::account_storage::save_display_overrides(&state.overrides_path, &overrides) + .await + { + tracing::warn!("failed to save display overrides: {e}"); + } + tracing::info!("display override saved: {account_id} = {display_name}"); + Ok(()) +} + +/// Save custom account sort order (persisted to display_overrides). +#[tauri::command] +pub async fn set_account_order( + order: Vec, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + let mut overrides = state.display_overrides.write().await; + overrides.order = order; + if let Err(e) = + crate::services::account_storage::save_display_overrides(&state.overrides_path, &overrides) + .await + { + tracing::warn!("failed to save display overrides: {e}"); + } + tracing::info!("account order saved ({} entries)", overrides.order.len()); + Ok(()) +} + +/// Get all local display name overrides. +#[tauri::command] +pub async fn get_display_overrides( + state: State<'_, AppState>, +) -> Result, ErrorDto> { + let overrides = state.display_overrides.read().await; + Ok(overrides.names.clone()) +} + /// Retrieve the authenticated user's email address (context menu action). /// /// Delegates to [`beanfun_service::get_email`]. Returns an empty string diff --git a/src-tauri/src/commands/launcher.rs b/src-tauri/src/commands/launcher.rs index ce2079e..d23dcfe 100644 --- a/src-tauri/src/commands/launcher.rs +++ b/src-tauri/src/commands/launcher.rs @@ -225,8 +225,9 @@ pub async fn launch_game( // 9. Auto-kill Patcher.exe (respects config toggle) if config.auto_kill_patcher { let game_dir = launch_cmd.working_dir.clone(); + let app_for_patcher = app.clone(); tauri::async_runtime::spawn(async move { - kill_patcher_loop(&game_dir).await; + kill_patcher_loop(&game_dir, &app_for_patcher).await; }); } @@ -373,8 +374,9 @@ pub async fn launch_game_direct( // Auto-kill patcher if config.auto_kill_patcher { let game_dir = launch_cmd.working_dir.clone(); + let app_for_patcher = app.clone(); tauri::async_runtime::spawn(async move { - kill_patcher_loop(&game_dir).await; + kill_patcher_loop(&game_dir, &app_for_patcher).await; }); } @@ -398,7 +400,7 @@ pub async fn launch_game_direct( /// Uses native Windows Toolhelp32 APIs to enumerate processes in-process, /// avoiding the `wmic.exe` console window popup that the previous /// implementation caused (wmic spawns a visible console every 100ms). -async fn kill_patcher_loop(game_dir: &str) { +async fn kill_patcher_loop(game_dir: &str, app: &tauri::AppHandle) { #[cfg(target_os = "windows")] { let patcher_path = std::path::Path::new(game_dir) @@ -418,6 +420,17 @@ async fn kill_patcher_loop(game_dir: &str) { .unwrap_or(false); if found { + // Get client version from game exe + use tauri::Emitter; + let game_exe = std::path::Path::new(game_dir).join("MapleStory.exe"); + let client_version = get_exe_version(&game_exe); + // Try to get server version from MapleStory login server + let server_version = get_server_version().await; + let payload = serde_json::json!({ + "clientVersion": client_version, + "serverVersion": server_version, + }); + let _ = app.emit("patcher-killed", payload); return; } } @@ -426,6 +439,117 @@ async fn kill_patcher_loop(game_dir: &str) { #[cfg(not(target_os = "windows"))] { let _ = game_dir; + let _ = app; + } +} + +/// Get the product version string from a Windows PE executable. +/// Returns something like "1.2.437.1" or empty string on failure. +fn get_exe_version(path: &std::path::Path) -> String { + #[cfg(target_os = "windows")] + { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + + let wide: Vec = OsStr::new(path) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + unsafe { + let size = windows_sys::Win32::Storage::FileSystem::GetFileVersionInfoSizeW( + wide.as_ptr(), + std::ptr::null_mut(), + ); + if size == 0 { + return String::new(); + } + let mut buf = vec![0u8; size as usize]; + if windows_sys::Win32::Storage::FileSystem::GetFileVersionInfoW( + wide.as_ptr(), + 0, + size, + buf.as_mut_ptr() as *mut _, + ) == 0 + { + return String::new(); + } + let mut ptr: *mut std::ffi::c_void = std::ptr::null_mut(); + let mut len: u32 = 0; + let sub: Vec = OsStr::new("\\") + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + if windows_sys::Win32::Storage::FileSystem::VerQueryValueW( + buf.as_ptr() as *const _, + sub.as_ptr(), + &mut ptr, + &mut len, + ) == 0 + { + return String::new(); + } + let info = &*(ptr as *const windows_sys::Win32::Storage::FileSystem::VS_FIXEDFILEINFO); + let major = info.dwProductVersionMS & 0xFFFF; // ProductMinorPart + let minor = (info.dwProductVersionLS >> 16) & 0xFFFF; // FileBuildPart + format!("{major}.{minor}") + } + } + #[cfg(not(target_os = "windows"))] + { + let _ = path; + String::new() + } +} + +/// Get MapleStory server version by connecting to the login server. +/// Reads the handshake packet: skip 2 bytes, read u16 major, read maple string minor. +async fn get_server_version() -> String { + use tokio::io::AsyncReadExt; + use tokio::net::TcpStream; + + let result: Result> = async { + let mut stream = tokio::time::timeout( + std::time::Duration::from_secs(3), + TcpStream::connect("tw.login.maplestory.beanfun.com:8484"), + ) + .await??; + + let mut buf = [0u8; 256]; + let n = tokio::time::timeout(std::time::Duration::from_secs(3), stream.read(&mut buf)) + .await??; + + if n < 6 { + return Ok(String::new()); + } + + // Handshake: [u16 packet_len] [u16 major_version] [u16 str_len] [str minor] ... + let major = u16::from_le_bytes([buf[2], buf[3]]); + let str_len = u16::from_le_bytes([buf[4], buf[5]]) as usize; + let minor = if n >= 6 + str_len { + String::from_utf8_lossy(&buf[6..6 + str_len]).to_string() + } else { + String::new() + }; + + let minor_clean = minor.split(':').next().unwrap_or("").to_string(); + if minor_clean.is_empty() { + Ok(format!("{major}")) + } else { + Ok(format!("{major}.{minor_clean}")) + } + } + .await; + + match result { + Ok(v) => { + tracing::info!("MapleStory server version: {v}"); + v + } + Err(e) => { + tracing::warn!("failed to get server version: {e}"); + String::new() + } } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 87f2b25..8a8de63 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -136,6 +136,9 @@ pub fn run() { commands::account::get_remain_point, commands::account::auto_paste_otp, commands::account::change_account_display_name, + commands::account::set_display_override, + commands::account::set_account_order, + commands::account::get_display_overrides, commands::account::get_auth_email, commands::launcher::launch_game, commands::launcher::launch_game_direct, @@ -229,6 +232,16 @@ pub fn run() { accounts_path.display() ); + let overrides_path = config_dir.join("display_overrides.json"); + let display_overrides = tauri::async_runtime::block_on(async { + account_storage::load_display_overrides(&overrides_path).await + }); + tracing::info!( + "loaded {} display overrides from {}", + display_overrides.names.len(), + overrides_path.display() + ); + // 4. Initialise AppState with loaded config. let auto_update_enabled = config.auto_update; let update_channel = config.update_channel.clone(); @@ -244,6 +257,8 @@ pub fn run() { config_path, saved_accounts: tokio::sync::RwLock::new(saved_accounts), accounts_path, + overrides_path, + display_overrides: tokio::sync::RwLock::new(display_overrides), http_client, }; diff --git a/src-tauri/src/models/app_state.rs b/src-tauri/src/models/app_state.rs index 44b28d7..4a4d8ca 100644 --- a/src-tauri/src/models/app_state.rs +++ b/src-tauri/src/models/app_state.rs @@ -25,6 +25,10 @@ pub struct AppState { pub saved_accounts: RwLock>, /// Path to the `accounts.json` file on disk. pub accounts_path: PathBuf, + /// Path to `display_overrides.dat` for local account customizations. + pub overrides_path: PathBuf, + /// Local account customizations (display names + sort order). + pub display_overrides: RwLock, /// A shared HTTP client for non-session operations (update checks, etc.) pub http_client: reqwest::Client, } diff --git a/src-tauri/src/services/account_storage.rs b/src-tauri/src/services/account_storage.rs index 919b7fc..4099aa9 100644 --- a/src-tauri/src/services/account_storage.rs +++ b/src-tauri/src/services/account_storage.rs @@ -187,3 +187,96 @@ pub fn remove_account(accounts: &mut Vec, region: &str, account: & accounts.retain(|a| !(a.region == region && a.account == account)); accounts.len() < before } + +// --------------------------------------------------------------------------- +// Display name overrides (local-only renames, DPAPI encrypted) +// --------------------------------------------------------------------------- + +/// Local account customizations: display name overrides + sort order. +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +pub struct DisplayOverrides { + /// account_id → custom display name + #[serde(default)] + pub names: std::collections::HashMap, + /// Custom account sort order (list of account IDs) + #[serde(default)] + pub order: Vec, +} + +/// Load display overrides from DPAPI-encrypted .dat + .key files. +pub async fn load_display_overrides(path: &Path) -> DisplayOverrides { + let dat_path = path.with_extension("dat"); + let key_path = path.with_extension("key"); + + if dat_path.exists() && key_path.exists() { + let ciphertext = match tokio::fs::read(&dat_path).await { + Ok(d) => d, + Err(_) => return DisplayOverrides::default(), + }; + let entropy = match tokio::fs::read(&key_path).await { + Ok(k) => k, + Err(_) => return DisplayOverrides::default(), + }; + return match dpapi::unprotect(&ciphertext, &entropy) { + Ok(plaintext) => { + let json = String::from_utf8_lossy(&plaintext); + serde_json::from_str(&json).unwrap_or_default() + } + Err(e) => { + tracing::warn!("failed to decrypt display overrides: {e}"); + DisplayOverrides::default() + } + }; + } + + // Legacy plaintext fallback + auto-migrate + if path.exists() { + if let Ok(json) = tokio::fs::read_to_string(path).await { + if let Ok(o) = serde_json::from_str::(&json) { + let _ = save_display_overrides(path, &o).await; + let _ = tokio::fs::remove_file(path).await; + return o; + } + if let Ok(map) = + serde_json::from_str::>(&json) + { + let o = DisplayOverrides { + names: map, + order: Vec::new(), + }; + let _ = save_display_overrides(path, &o).await; + let _ = tokio::fs::remove_file(path).await; + return o; + } + } + } + + DisplayOverrides::default() +} + +/// Save display overrides encrypted with DPAPI. +pub async fn save_display_overrides( + path: &Path, + overrides: &DisplayOverrides, +) -> Result<(), String> { + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| format!("failed to create dir: {e}"))?; + } + let json = serde_json::to_string(overrides) + .map_err(|e| format!("failed to serialize overrides: {e}"))?; + + let (ciphertext, entropy) = + dpapi::protect(json.as_bytes()).map_err(|e| format!("DPAPI encrypt failed: {e}"))?; + + let dat_path = path.with_extension("dat"); + let key_path = path.with_extension("key"); + + tokio::fs::write(&dat_path, &ciphertext) + .await + .map_err(|e| format!("failed to write overrides.dat: {e}"))?; + tokio::fs::write(&key_path, &entropy) + .await + .map_err(|e| format!("failed to write overrides.key: {e}")) +} diff --git a/src-tauri/src/services/beanfun_service.rs b/src-tauri/src/services/beanfun_service.rs index 40c4ea7..9231373 100644 --- a/src-tauri/src/services/beanfun_service.rs +++ b/src-tauri/src/services/beanfun_service.rs @@ -304,11 +304,18 @@ pub async fn get_remain_point(client: &Client, region: &Region) -> Result Result { - let url = "https://bfweb.hk.beanfun.com/generic_handlers/gamezone.ashx"; + // Only TW region has a server-side rename API + if *region != Region::TW { + // HK: no API, return false so caller saves locally + return Ok(false); + } + + let url = "https://tw.beanfun.com/generic_handlers/gamezone.ashx"; let form = [ ("strFunction", "ChangeServiceAccountDisplayName"), diff --git a/src/App.tsx b/src/App.tsx index 75ec4a2..6e8538c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { useConfig } from "./lib/hooks/use-config"; import { Titlebar } from "./features/shared/Titlebar"; import { ErrorToastContainer } from "./features/shared/ErrorToast"; import { UpdateDialog } from "./features/shared/UpdateDialog"; +import { Modal } from "./features/shared/Modal"; import { LoginPage } from "./features/login/LoginPage"; import { MainPage } from "./features/launcher/MainPage"; import { ToolboxPage } from "./features/toolbox/ToolboxPage"; @@ -86,6 +87,23 @@ export function App() { // Show banner on all pages when update available and dialog dismissed const showBanner = !pendingUpdate && availableUpdate && !bannerDismissed; + // Patcher killed notification + const [patcherInfo, setPatcherInfo] = useState<{ + clientVersion: string; + serverVersion: string; + } | null>(null); + useEffect(() => { + const unlisten = listen<{ clientVersion: string; serverVersion: string }>( + "patcher-killed", + (ev) => { + setPatcherInfo(ev.payload); + }, + ); + return () => { + unlisten.then((f) => f()); + }; + }, []); + // Adjust window height when update banner appears or disappears const bannerHeight = 28; useEffect(() => { @@ -145,7 +163,7 @@ export function App() { if (info) onUpdate(info); }) .catch(() => {}); - }, 3000); + }, 6000); return () => { clearTimeout(timer); @@ -192,6 +210,49 @@ export function App() { {pendingUpdate && ( setPendingUpdate(null)} /> )} + {patcherInfo && ( + setPatcherInfo(null)} title={t("launcher.patcher_title")}> +
+

{t("launcher.patcher_killed")}

+ {patcherInfo.clientVersion && ( +

+ {t("launcher.patcher_client_ver")}:{" "} + + {patcherInfo.clientVersion} + +

+ )} + {patcherInfo.serverVersion && ( +

+ {t("launcher.patcher_server_ver")}:{" "} + + {patcherInfo.serverVersion} + +

+ )} +

{t("launcher.patcher_hint")}

+
+ + +
+
+
+ )} ); } diff --git a/src/features/debug/DebugConsole.tsx b/src/features/debug/DebugConsole.tsx index bb596fb..4ec41ca 100644 --- a/src/features/debug/DebugConsole.tsx +++ b/src/features/debug/DebugConsole.tsx @@ -40,12 +40,19 @@ function maskSensitive(msg: string): string { } function parseLogLine(line: string): LogEntry | null { - const m = line.match(/^(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)\s+(TRACE|DEBUG|INFO|WARN|ERROR)\s+(.+)$/); + // Match tracing format: "2026-04-22T04:48:19.089+08:00 INFO module::path: message" + // Also matches UTC "Z" suffix and optional span fields like "[]" + const m = line.match( + /^(\d{4}-\d{2}-\d{2}T[\d:.]+(?:Z|[+-]\d{2}:\d{2}))\s+(TRACE|DEBUG|INFO|WARN|ERROR)\s+(.+)$/, + ); if (m) { + let msg = m[3] ?? ""; + // Strip leading "[] " span markers and module path prefix + msg = msg.replace(/^(\[\S*\]\s*)*/, "").replace(/^\S+::\S+:\s*/, ""); return { - ts: (m[1] ?? "").replace("T", " ").replace(/Z$/, "").slice(11, 19), + ts: (m[1] ?? "").replace("T", " ").slice(11, 19), level: (m[2] ?? "info").toLowerCase() as LogLevel, - msg: maskSensitive(m[3] ?? ""), + msg: maskSensitive(msg), }; } if (line.trim()) return { ts: "", level: "info", msg: maskSensitive(line) }; @@ -64,17 +71,23 @@ export function DebugConsole() { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; }, [logs, autoScroll]); + // Initial load + periodic refresh useEffect(() => { - invoke("get_recent_logs") - .then((text) => { - setLogs( - text - .split("\n") - .map(parseLogLine) - .filter((e): e is LogEntry => e !== null), - ); - }) - .catch(() => {}); + function fetchLogs() { + invoke("get_recent_logs") + .then((text) => { + setLogs( + text + .split("\n") + .map(parseLogLine) + .filter((e): e is LogEntry => e !== null), + ); + }) + .catch(() => {}); + } + fetchLogs(); + const interval = setInterval(fetchLogs, 2000); + return () => clearInterval(interval); }, []); useEffect(() => { @@ -178,6 +191,7 @@ export function DebugConsole() { }} > setSearch(e.target.value)} placeholder="Filter..." diff --git a/src/features/launcher/AccountContextMenu.tsx b/src/features/launcher/AccountContextMenu.tsx index 2b200c0..a5cd3aa 100644 --- a/src/features/launcher/AccountContextMenu.tsx +++ b/src/features/launcher/AccountContextMenu.tsx @@ -3,7 +3,9 @@ import { useTranslation } from "../../lib/i18n"; import { open } from "@tauri-apps/plugin-shell"; import { commands } from "../../lib/tauri"; import { useRefreshAccounts } from "../../lib/hooks/use-accounts"; +import { useQueryClient } from "@tanstack/react-query"; import { useAuthStore } from "../../lib/stores/auth-store"; +import { useConfigStore } from "../../lib/stores/config-store"; import { Modal } from "../shared/Modal"; import type { GameAccountDto } from "../../lib/types"; @@ -127,15 +129,19 @@ function AccountDetailView({ function EditAccountView({ account, t, + region, onSave, onCancel, }: { account: GameAccountDto; t: (key: string) => string; - onSave: (name: string) => void; + region: string; + onSave: (name: string, syncToServer: boolean) => void; onCancel: () => void; }) { const [name, setName] = useState(account.displayName); + const [syncToServer, setSyncToServer] = useState(false); + const isTW = region === "TW"; return (
@@ -144,13 +150,30 @@ function EditAccountView({ setName(e.target.value)} onKeyDown={(e) => { - if (e.key === "Enter" && name) onSave(name); + if (e.key === "Enter" && name) onSave(name, syncToServer); }} className="rounded-lg border border-[var(--tb-border)] bg-[var(--bg)] px-3 py-2 text-xs text-[var(--text)] transition-colors outline-none focus:border-accent" /> + {isTW ? ( + + ) : ( + {t("launcher.context.edit_local_only")} + )}
); } @@ -200,30 +291,54 @@ function ListItem({ account, isSelected, isCopied, + isDragging, + isBumped, + idx, onSelect, onContextMenu, onCopy, + onGripDown, copyTitle, }: { account: GameAccountDto; isSelected: boolean; isCopied: boolean; + isDragging: boolean; + isBumped: boolean; + idx: number; onSelect: () => void; onContextMenu: (e: React.MouseEvent) => void; onCopy: (e: React.MouseEvent) => void; + onGripDown: (e: React.MouseEvent) => void; copyTitle: string; }) { const initial = account.displayName.charAt(0).toUpperCase(); return ( diff --git a/src/features/launcher/MainPage.tsx b/src/features/launcher/MainPage.tsx index aeae280..a74c509 100644 --- a/src/features/launcher/MainPage.tsx +++ b/src/features/launcher/MainPage.tsx @@ -47,6 +47,7 @@ export function MainPage() { }, 3000); return () => clearInterval(interval); }, [gamePid, gameRunning, setGamePid, setGameRunning]); + const [remainPoint, setRemainPoint] = useState(0); const [showRelaunchConfirm, setShowRelaunchConfirm] = useState(false); const [showLogoutConfirm, setShowLogoutConfirm] = useState(false); diff --git a/src/features/launcher/SessionTabs.tsx b/src/features/launcher/SessionTabs.tsx index 0b796b3..4ab84f3 100644 --- a/src/features/launcher/SessionTabs.tsx +++ b/src/features/launcher/SessionTabs.tsx @@ -139,6 +139,9 @@ export function SessionTabs() { {isEditing ? ( setEditValue(e.target.value)} onBlur={commitRename} diff --git a/src/features/login/NormalLoginForm.tsx b/src/features/login/NormalLoginForm.tsx index 6fa8a02..9387fe8 100644 --- a/src/features/login/NormalLoginForm.tsx +++ b/src/features/login/NormalLoginForm.tsx @@ -249,6 +249,7 @@ export function NormalLoginForm({