diff --git a/docs/encryption-flow.md b/docs/encryption-flow.md new file mode 100644 index 0000000..b3aa01c --- /dev/null +++ b/docs/encryption-flow.md @@ -0,0 +1,595 @@ +# 🔐 Luồng Hoạt Động - Hệ Thống Lưu Mật Khẩu AES-256 + +**Version:** 2.0 (AES-256 Encryption) +**File:** `src/utils/rememberMe.ts` +**Loại Bảo Mật:** AES-256 (Advanced Encryption Standard) + +--- + +## 📋 Mục Lục + +1. [Tổng Quan](#tổng-quan) +2. [Kiến Thức Nền Tảng](#kiến-thức-nền-tảng) +3. [Luồng Lưu Thông Tin](#luồng-lưu-thông-tin) +4. [Luồng Tải Thông Tin](#luồng-tải-thông-tin) +5. [Chi Tiết Mã Hóa AES-256](#chi-tiết-mã-hóa-aes-256) +6. [Ví Dụ Cụ Thể](#ví-dụ-cụ-thể) +7. [Xử Lý Lỗi](#xử-lý-lỗi) +8. [So Sánh Bảo Mật](#so-sánh-bảo-mật) + +--- + +## 🎯 Tổng Quan + +Hệ thống lưu mật khẩu "Remember Me" sử dụng **AES-256 encryption** để bảo vệ thông tin đăng nhập người dùng khi lưu vào `localStorage`. + +### Các Bước Chính: + +``` +┌─────────────────────────────────────────────────────────┐ +│ 1. Nhập Email + Password từ form đăng nhập │ +├─────────────────────────────────────────────────────────┤ +│ 2. Tạo Object: { email, password } │ +├─────────────────────────────────────────────────────────┤ +│ 3. Chuyển thành JSON string │ +├─────────────────────────────────────────────────────────┤ +│ 4. Mã hóa AES-256 (với SECRET_KEY) │ +├─────────────────────────────────────────────────────────┤ +│ 5. Lưu ciphertext vào localStorage │ +├─────────────────────────────────────────────────────────┤ +│ 6. Khi cần: Giải mã và lấy lại thông tin │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## 🏫 Kiến Thức Nền Tảng + +### AES-256 là gì? + +**AES** = Advanced Encryption Standard (chuẩn mã hóa quốc tế) +**256** = Khóa bí mật dài 256 bit (32 bytes) + +### Tại sao dùng AES-256? + +| Tiêu Chí | Chi Tiết | +| --- | --- | +| **Độ An Toàn** | ✅ Hầu như không thể bẻ khóa (2^256 khả năng) | +| **Tốc Độ** | ✅ Nhanh (xử lý được tỷ tấn dữ liệu/giây) | +| **Tiêu Chuẩn** | ✅ Được chính phủ Mỹ & thế giới sử dụng | +| **IV (Initialization Vector)** | ✅ Tự động tạo ngẫu nhiên mỗi lần | +| **Pattern Hiding** | ✅ Cùng plaintext → khác ciphertext | + +### IV (Initialization Vector) là gì? + +- **Định nghĩa:** Một chuỗi bit ngẫu nhiên 128-bit (16 bytes) +- **Chức năng:** Đảm bảo cùng plaintext lại được mã hóa khác +- **Ví dụ:** + + ``` + Plaintext: "password" + + Lần 1: IV = random_1 → Ciphertext = "aB3dE7..." + Lần 2: IV = random_2 → Ciphertext = "xY9pQ2..." (khác!) + Lần 3: IV = random_3 → Ciphertext = "kL5mR8..." (khác!) + + Cả 3 lần đếu giải mã lại thành "password" + ``` + +--- + +## 💾 Luồng Lưu Thông Tin + +### Hàm: `saveCredentials(email, password)` + +``` +Đầu Vào: + email = "user@gmail.com" + password = "MyPassword123" + ↓ +┌────────────────────────────────┐ +│ Bước 1: Tạo Object Credentials │ +└────────────────────────────────┘ + credentials = { + email: "user@gmail.com", + password: "MyPassword123" + } + ↓ +┌─────────────────────────────────┐ +│ Bước 2: Chuyển Object → JSON │ +└─────────────────────────────────┘ + json = '{"email":"user@gmail.com","password":"MyPassword123"}' + ↓ + ↓ +┌───────────────────────────────────────────┐ +│ Bước 3: Mã Hóa (AES-256) │ +│ │ +│ Hàm: encrypt(json) │ +│ CryptoJS.AES.encrypt(json, SECRET_KEY) │ +└───────────────────────────────────────────┘ + ↓ + ↓ + encrypted = "U2FsdGVkX1+ZzO2jNxNKbvH..." + (output = Salt + IV + Ciphertext, tất cả dạng Base64) + ↓ +┌─────────────────────────────────────────┐ +│ Bước 4: Lưu vào localStorage │ +│ localStorage.setItem(REMEMBER_ME_KEY, │ +│ encrypted) │ +└─────────────────────────────────────────┘ + ↓ + localStorage['smatec_remember_login'] = "U2FsdGVkX1+ZzO2jNxNKbvH..." + +Đầu Ra: ✅ Mật khẩu được mã hóa & lưu an toàn +``` + +### Code Chi Tiết: + +```typescript +export function saveCredentials(email: string, password: string): void { + try { + // Bước 1: Tạo object + const credentials: RememberedCredentials = { email, password }; + + // Bước 2: JSON stringify + const json = JSON.stringify(credentials); + // Kết quả: '{"email":"user@gmail.com","password":"MyPassword123"}' + + // Bước 3: Mã hóa AES-256 + const encrypted = encrypt(json); + // Kết quả: "U2FsdGVkX1+ZzO2jNxNKbvH..." + + // Bước 4: Lưu vào localStorage + localStorage.setItem(REMEMBER_ME_KEY, encrypted); + // Lưu thành công ✅ + } catch (error) { + console.error('Error saving credentials:', error); + } +} +``` + +--- + +## 📂 Luồng Tải Thông Tin + +### Hàm: `loadCredentials()` + +``` +Đầu Vào: (Không có tham số - tự động từ localStorage) + ↓ +┌────────────────────────────────────────┐ +│ Bước 1: Lấy Dữ Liệu Từ localStorage │ +└────────────────────────────────────────┘ + encrypted = localStorage.getItem(REMEMBER_ME_KEY) + // Kết quả: "U2FsdGVkX1+ZzO2jNxNKbvH..." hoặc null + ↓ +┌────────────────────────────────┐ +│ Bước 2: Kiểm Tra Null │ +└────────────────────────────────┘ + if (!encrypted) { + return null ❌ (Không có dữ liệu lưu) + } + ↓ +┌───────────────────────────────────────────┐ +│ Bước 3: Giải Mã (AES-256) │ +│ │ +│ Hàm: decrypt(encrypted) │ +│ CryptoJS.AES.decrypt(encrypted, │ +│ SECRET_KEY) │ +└───────────────────────────────────────────┘ + ↓ + ↓ + decrypted_text = '{"email":"user@gmail.com","password":"MyPassword123"}' + ↓ +┌──────────────────────────────────┐ +│ Bước 4: Kiểm Tra Decode Hợp Lệ │ +└──────────────────────────────────┘ + if (!decrypted_text) { + clearCredentials() // Xóa dữ liệu lỗi + return null ❌ + } + ↓ +┌──────────────────────────────┐ +│ Bước 5: Parse JSON │ +└──────────────────────────────┘ + credentials = JSON.parse(decrypted_text) + // Kết quả: + // { + // email: "user@gmail.com", + // password: "MyPassword123" + // } + ↓ + return credentials ✅ + +Đầu Ra: RememberedCredentials object hoặc null +``` + +### Code Chi Tiết: + +```typescript +export function loadCredentials(): RememberedCredentials | null { + try { + // Bước 1: Lấy từ localStorage + const encrypted = localStorage.getItem(REMEMBER_ME_KEY); + + // Bước 2: Kiểm tra null + if (!encrypted) { + return null; + } + + // Bước 3: Giải mã AES-256 + const decrypted = decrypt(encrypted); + // Kết quả: '{"email":"user@gmail.com","password":"MyPassword123"}' + + // Bước 4: Kiểm tra giải mã hợp lệ + if (!decrypted) { + clearCredentials(); + return null; + } + + // Bước 5: Parse JSON + const credentials: RememberedCredentials = JSON.parse(decrypted); + + // Trả về object credentials ✅ + return credentials; + } catch (error) { + console.error('Error loading credentials:', error); + clearCredentials(); + return null; + } +} +``` + +--- + +## 🔐 Chi Tiết Mã Hóa AES-256 + +### Hàm Encrypt + +```typescript +function encrypt(data: string): string { + try { + // CryptoJS.AES.encrypt() tự động: + // 1. Tạo salt ngẫu nhiên (8 bytes) + // 2. Từ salt + SECRET_KEY → tạo key & IV (PBKDF2) + // 3. Mã hóa data bằng AES-256-CBC + // 4. Trả về: Salt + IV + Ciphertext (tất cả Base64) + + const encrypted = CryptoJS.AES.encrypt(data, SECRET_KEY).toString(); + return encrypted; + } catch (error) { + console.error('Encryption error:', error); + return ''; + } +} +``` + +**Cấu Trúc Output:** + +``` +Ciphertext cuối cùng = "U2FsdGVkX1+ZzO2jNxNKbvH..." + ↓ + Được chia thành 3 phần: +│ Part 1 │ Part 2 │ Part 3 │ +│ Magic String │ Salt (8) │ IV + Ciphertext │ +│ "Salted__" │ random │ │ +└──────────────┴───────────┴─────────────────┘ + ↓ + Tất cả encode Base64 +``` + +### Hàm Decrypt + +```typescript +function decrypt(encrypted: string): string { + try { + // CryptoJS.AES.decrypt() tự động: + // 1. Decode Base64 ciphertext + // 2. Trích xuất Salt từ đầu + // 3. Từ salt + SECRET_KEY → tạo key & IV (PBKDF2) + // 4. Giải mã dữ liệu bằng AES-256-CBC + // 5. Trả về plaintext + + const decrypted = CryptoJS.AES.decrypt(encrypted, SECRET_KEY); + + // Chuyển từ WordArray sang UTF8 string + const result = decrypted.toString(CryptoJS.enc.Utf8); + return result; + } catch (error) { + console.error('Decryption error:', error); + return ''; + } +} +``` + +### Biểu Đồ Quy Trình AES-256 + +``` +ENCRYPTION SIDE (Lưu Mật Khẩu) +═══════════════════════════════════════════════════════ + +Plaintext: "password" + │ + ├─→ [PBKDF2 + Salt] ──→ Key (256-bit) + IV (128-bit) + │ │ + │ ↓ + └──────────────────→ [AES-256-CBC Engine] + │ + ↓ + Ciphertext + │ + ↓ + [Base64 Encode] + │ + ↓ + "U2FsdGVkX1+ZzO2jNxNKbvH..." + + +DECRYPTION SIDE (Tải Mật Khẩu) +═══════════════════════════════════════════════════════ + +Ciphertext: "U2FsdGVkX1+ZzO2jNxNKbvH..." + │ + ├─→ [Base64 Decode] + │ + ├─→ Extract Salt (8 bytes) + │ │ + │ └──→ [PBKDF2] ──→ Key (256-bit) + IV (128-bit) + │ + ├─→ Extract Ciphertext + │ │ + │ └──→ [AES-256-CBC Engine] + │ │ + │ ↓ + └──────────→ Plaintext: "password" +``` + +--- + +## 💡 Ví Dụ Cụ Thể + +### Ví Dụ 1: Lưu Email & Mật Khẩu + +**Input:** + +```javascript +saveCredentials('john@example.com', 'SecurePass2024!'); +``` + +**Bước Bước:** + +| Bước | Dữ Liệu | Mô Tả | +| --- | --- | --- | +| 1 | `{ email: "john@example.com", password: "SecurePass2024!" }` | Object credentials | +| 2 | `'{"email":"john@example.com","password":"SecurePass2024!"}'` | JSON stringify | +| 3 | `U2FsdGVkX1...abc123...` | AES-256 encrypt + Base64 | +| 4 | localStorage | Lưu thành công ✅ | + +**Trong localStorage:** + +```javascript +localStorage = { + smatec_remember_login: 'U2FsdGVkX1+ZzO2jNxNKbvHJmVaFptBWc0p...', +}; +``` + +### Ví Dụ 2: Tải Email & Mật Khẩu + +**Input:** + +```javascript +loadCredentials(); +``` + +**Bước Bước:** + +| Bước | Dữ Liệu | Mô Tả | +| --- | --- | --- | +| 1 | `"U2FsdGVkX1+ZzO2jNxNKbvHJmVaFptBWc0p..."` | Lấy từ localStorage | +| 2 | ✅ Tồn tại | Kiểm tra có dữ liệu | +| 3 | `'{"email":"john@example.com","password":"SecurePass2024!"}'` | AES-256 decrypt | +| 4 | ✅ Valid JSON | Kiểm tra hợp lệ | +| 5 | `{ email: "john@example.com", password: "SecurePass2024!" }` | JSON parse | +| 6 | Trả về object | ✅ | + +**Output:** + +```javascript +{ + email: "john@example.com", + password: "SecurePass2024!" +} +``` + +--- + +## ⚠️ Xử Lý Lỗi + +### Trường Hợp 1: Không Có Dữ Liệu Lưu + +``` +loadCredentials() + ↓ +Kiểm tra localStorage + ↓ +encrypted === null + ↓ +return null ← Không có gì để tải +``` + +### Trường Hợp 2: Dữ Liệu Bị Hỏng + +``` +loadCredentials() + ↓ +Lấy được dữ liệu + ↓ +Giải mã thất bại (decrypt() trả về '') + ↓ +clearCredentials() ← Xóa dữ liệu lỗi + ↓ +return null ← Không thể tải +``` + +### Trường Hợp 3: JSON Parse Lỗi + +``` +loadCredentials() + ↓ +Giải mã thành công + ↓ +JSON.parse() thất bại (dữ liệu không phải JSON) + ↓ +catch block → clearCredentials() + ↓ +return null ← Lỗi JSON +``` + +### Trường Hợp 4: LocalStorage Không Khả Dụng + +``` +Các hàm try-catch sẽ bắt lỗi + ↓ +Trả về false / null / log error + ↓ +Ứng dụng vẫn hoạt động bình thường (graceful degradation) +``` + +--- + +## 📊 So Sánh Bảo Mật + +### XOR Cipher (Cũ) vs AES-256 (Mới) + +``` +╔════════════════════════════════════════════════════════════════╗ +║ XOR CIPHER (CŨ) │ AES-256 (MỚI) ║ +╠════════════════════════════════════════════════════════════════╣ +║ Mã Hóa Kiểu │ XOR từng ký tự │ Mã hóa khối ║ +║ Độ An Toàn │ ⚠️ Rất Thấp │ ✅ Cực Cao ║ +║ Key Length │ Xoay vòng bằng khóa │ 256-bit (32 bytes) ║ +║ IV │ ❌ Không có │ ✅ Ngẫu nhiên ║ +║ Brute Force │ ⚠️ Dễ (2^key_length) │ ✅ Không Thể ║ +║ Pattern │ ⚠️ Có pattern │ ✅ Ẩn pattern ║ +║ │ (cùng text → lại) │ (random IV) ║ +║ Block Mode │ N/A │ ✅ CBC Mode ║ +║ Salt │ ❌ Không │ ✅ Có PBKDF2 ║ +║ Performance │ ✅ Cực Nhanh │ ✅ Nhanh (HW opt) ║ +║ Standard │ ❌ Custom/Weak │ ✅ NIST/Military ║ +╚════════════════════════════════════════════════════════════════╝ +``` + +### Ví Dụ Tấn Công: + +**XOR Cipher:** + +``` +Nếu biết: +- Ciphertext = "abc123" +- Plaintext đối với ciphertext khác = "password" + +Có thể dễ dàng suy ra SECRET_KEY bằng XOR: +plaintext XOR ciphertext = key +``` + +**AES-256:** + +``` +Ngay cả biết: +- Ciphertext = "U2FsdGVkX1+ZzO2jNxNKbvH..." +- Plaintext = "password" +- IV được sử dụng + +Vẫn không thể suy ra SECRET_KEY (cần brute force 2^256 khả năng) +≈ 10^77 năm với máy tính hiện đại +``` + +--- + +## 🔧 Các Hàm Hỗ Trợ + +### 1. `clearCredentials()` + +```typescript +export function clearCredentials(): void { + localStorage.removeItem(REMEMBER_ME_KEY); + // Xóa hoàn toàn dữ liệu khỏi localStorage + // Dùng khi: Người dùng logout hoặc "Forget Me" +} +``` + +### 2. `hasSavedCredentials()` + +```typescript +export function hasSavedCredentials(): boolean { + try { + const encrypted = localStorage.getItem(REMEMBER_ME_KEY); + return encrypted !== null; + // Kiểm tra xem có dữ liệu lưu hay không + // Dùng khi: Hiển thị checkbox "Remember Me" + } catch (error) { + return false; + } +} +``` + +--- + +## 🎯 Luồng Sử Dụng Trong Ứng Dụng + +### Sơ Đồ Hoàn Chỉnh: + +``` +LOGIN PAGE + │ + ├─→ User nhập email + password + │ + ├─→ Tick checkbox "Remember Me"? + │ │ + │ ├─→ YES: saveCredentials(email, pwd) + │ │ └─→ Mã hóa + Lưu localStorage ✅ + │ │ + │ └─→ NO: Không lưu gì + │ + ├─→ Gửi login request lên server + │ + └─→ Server xác thực OK ✅ + │ + └─→ Redirect tới Dashboard + + +SUBSEQUENT LOGIN (Lần Đăng Nhập Tiếp Theo) + │ + ├─→ Check: hasSavedCredentials()? + │ │ + │ ├─→ YES: loadCredentials() + │ │ └─→ Giải mã + Parse ✅ + │ │ └─→ Auto-fill form + │ │ + │ └─→ NO: Form trống (User nhập thủ công) + │ + └─→ User xác nhận + submit + │ + └─→ Login +``` + +--- + +## 📝 Tóm Tắt + +| Chức Năng | Hàm | Mô Tả | +| --- | --- | --- | +| Lưu | `saveCredentials(email, pwd)` | Mã hóa AES-256 + lưu localStorage | +| Tải | `loadCredentials()` | Lấy từ localStorage + giải mã | +| Xóa | `clearCredentials()` | Xóa hoàn toàn từ localStorage | +| Kiểm Tra | `hasSavedCredentials()` | Kiểm tra có dữ liệu hay không | + +## 🛡️ Kết Luận + +✅ **AES-256** được sử dụng để mã hóa thông tin sensitive +✅ **Random IV** đảm bảo mã hóa khác lần nữa +✅ **PBKDF2** làm việc với SECRET_KEY để tạo key mạnh +✅ **Try-Catch** xử lý tất cả lỗi gracefully +✅ **localStorage** là vị trí lưu phù hợp (with encrypted data) + +**Bảo mật hiện tại:** ⭐⭐⭐⭐⭐ (Military Grade) diff --git a/package.json b/package.json index 6653797..14635bd 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "antd": "^5.4.0", "chart.js": "^4.5.1", "classnames": "^2.5.1", + "crypto-js": "^4.2.0", "dayjs": "^1.11.19", "moment": "^2.30.1", "mqtt": "^5.15.0", @@ -35,6 +36,7 @@ "xterm-addon-fit": "^0.8.0" }, "devDependencies": { + "@types/crypto-js": "^4.2.2", "@types/react": "^18.0.33", "@types/react-dom": "^18.0.11", "@types/uuid": "^10.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48d6cf6..59cf474 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: classnames: specifier: ^2.5.1 version: 2.5.1 + crypto-js: + specifier: ^4.2.0 + version: 4.2.0 dayjs: specifier: ^1.11.19 version: 1.11.19 @@ -57,6 +60,9 @@ importers: specifier: ^0.8.0 version: 0.8.0(xterm@5.3.0) devDependencies: + '@types/crypto-js': + specifier: ^4.2.2 + version: 4.2.2 '@types/react': specifier: ^18.0.33 version: 18.3.27 @@ -1329,6 +1335,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==, tarball: https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz} + '@types/crypto-js@4.2.2': + resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==, tarball: https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==, tarball: https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz} @@ -2472,6 +2481,9 @@ packages: resolution: {integrity: sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ==, tarball: https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.1.tgz} engines: {node: '>= 0.10'} + crypto-js@4.2.0: + resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==, tarball: https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz} + css-blank-pseudo@3.0.3: resolution: {integrity: sha512-VS90XWtsHGqoM0t4KpH053c4ehxZ2E6HtGI7x68YFV0pTo/QmkV/YFA+NnlvK8guxZVNWGQhVNJGC39Q8XF4OQ==, tarball: https://registry.npmjs.org/css-blank-pseudo/-/css-blank-pseudo-3.0.3.tgz} engines: {node: ^12 || ^14 || >=16} @@ -8248,6 +8260,8 @@ snapshots: dependencies: '@babel/types': 7.28.6 + '@types/crypto-js@4.2.2': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 @@ -9996,6 +10010,8 @@ snapshots: randombytes: 2.1.0 randomfill: 1.0.4 + crypto-js@4.2.0: {} + css-blank-pseudo@3.0.3(postcss@8.5.6): dependencies: postcss: 8.5.6 diff --git a/src/constants/index.ts b/src/constants/index.ts index 09e0792..1d6bb29 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -24,6 +24,7 @@ export const ACCESS_TOKEN = 'access_token'; export const REFRESH_TOKEN = 'refresh_token'; export const THEME_KEY = 'theme'; export const TERMINAL_THEME_KEY = 'terminal_theme_key'; +export const REMEMBER_ME_KEY = 'smatec_remember_login'; // Global Constants export const LIMIT_TREE_LEVEL = 5; export const DEFAULT_PAGE_SIZE = 5; diff --git a/src/pages/Auth/index.tsx b/src/pages/Auth/index.tsx index 1b9d403..f39d22c 100644 --- a/src/pages/Auth/index.tsx +++ b/src/pages/Auth/index.tsx @@ -10,6 +10,12 @@ import { } from '@/services/master/AuthController'; import { checkRefreshTokenExpired } from '@/utils/jwt'; import { getDomainTitle, getLogoImage } from '@/utils/logo'; +import { + clearCredentials, + hasSavedCredentials, + loadCredentials, + saveCredentials, +} from '@/utils/rememberMe'; import { getBrowserId, getRefreshToken, @@ -21,8 +27,17 @@ import { } from '@/utils/storage'; import { LoginFormPage } from '@ant-design/pro-components'; import { FormattedMessage, history, useIntl, useModel } from '@umijs/max'; -import { Button, ConfigProvider, Flex, Image, message, theme } from 'antd'; -import { CSSProperties, useEffect, useState } from 'react'; +import { + Button, + Checkbox, + ConfigProvider, + Flex, + Form, + Image, + message, + theme, +} from 'antd'; +import { CSSProperties, useEffect, useRef, useState } from 'react'; import { flushSync } from 'react-dom'; import mobifontLogo from '../../../public/mobifont-logo.png'; import ForgotPasswordForm from './components/ForgotPasswordForm'; @@ -72,6 +87,12 @@ const LoginPage = () => { const { setInitialState } = useModel('@@initialState'); const [loginType, setLoginType] = useState('login'); const [pending2faToken, setPending2faToken] = useState(''); + const [pendingCredentials, setPendingCredentials] = useState<{ + email: string; + password: string; + rememberMe: boolean; + } | null>(null); + const formRef = useRef(null); // Listen for theme changes from ThemeSwitcherAuth useEffect(() => { @@ -122,8 +143,32 @@ const LoginPage = () => { checkLogin(); }, []); + // Load saved credentials on mount + useEffect(() => { + try { + if (hasSavedCredentials()) { + const saved = loadCredentials(); + if (saved && formRef.current) { + // Use setTimeout to ensure form is mounted before setting values + setTimeout(() => { + if (formRef.current) { + formRef.current.setFieldsValue({ + email: saved.email, + password: saved.password, + rememberMe: true, + }); + } + }, 100); + } + } + } catch (e) { + console.error('Error loading saved credentials:', e); + clearCredentials(); + } + }, []); + const handleLogin = async (values: any) => { - const { email, password } = values; + const { email, password, rememberMe } = values; if (loginType === 'login') { try { const resp = await apiLogin({ @@ -133,12 +178,20 @@ const LoginPage = () => { }); // Check if 2FA is enabled if (resp?.enabled2fa && resp?.token) { - // Save pending 2FA token and switch to OTP form + // Save pending credentials and 2FA token and switch to OTP form + setPendingCredentials({ email, password, rememberMe }); setPending2faToken(resp.token); setLoginType('otp'); return; } if (resp?.token) { + // Handle remember me - save or clear credentials + if (rememberMe) { + saveCredentials(email, password); + } else { + clearCredentials(); + } + setAccessToken(resp.token); setRefreshToken(resp.refresh_token || ''); const userInfo = await apiQueryProfile(); @@ -167,6 +220,18 @@ const LoginPage = () => { otp: values.otp || '', }); if (resp?.token) { + // Handle remember me - save or clear credentials + if (pendingCredentials) { + if (pendingCredentials.rememberMe) { + saveCredentials( + pendingCredentials.email, + pendingCredentials.password, + ); + } else { + clearCredentials(); + } + } + setAccessToken(resp.token); setRefreshToken(resp.refresh_token || ''); const userInfo = await apiQueryProfile(); @@ -244,6 +309,7 @@ const LoginPage = () => { const handleBackToLogin = () => { setPending2faToken(''); + setPendingCredentials(null); setLoginType('login'); }; @@ -261,6 +327,7 @@ const LoginPage = () => { > {contextHolder} { {loginType === 'otp' && } + {loginType === 'login' && ( + + + + + + )}