Compare commits

...

2 Commits

Author SHA1 Message Date
9aee5f4100 update function login
Some checks failed
Test / test (push) Has been cancelled
2026-06-16 09:57:55 +07:00
6fa15b69e7 Tạo tài liệu 2026-06-15 12:10:51 +07:00
21 changed files with 2592 additions and 32 deletions

View File

@@ -34,6 +34,8 @@ add_executable(lidar_manager_web
src/util/string_util.cpp
src/util/id_util.cpp
src/util/http_util.cpp
src/util/crypto_util.cpp
src/auth/auth_service.cpp
src/domain/layout_schema.cpp
src/domain/layout_profile.cpp
src/storage/state_repository.cpp

View File

@@ -30,6 +30,21 @@ Hoặc chỉ định:
Mở trình duyệt: `http://localhost:8080/`
### Đăng nhập (Signing in — MiR §2.1)
Trang web **bắt buộc đăng nhập**. Hai tab: tên/mật khẩu hoặc **Mã PIN** (keypad 4 số). Tài khoản mặc định (`data/auth.json`):
| User | Password | Nhóm |
|------|----------|------|
| Admin | admin | Administrators (full quyền) |
| User | user | Users (dashboard write, còn lại read) |
| Distributor | distributor | Distributors (full quyền) |
PIN 4 chữ số chỉ dùng được với user thuộc nhóm **Users** sau khi admin gán PIN (`PUT /api/users/:id`).
Tắt auth cho dev/test: `LM_AUTH_DISABLED=1 ./build/lidar_manager_web …`
Tài liệu đầy đủ: [`docs/Reference_guide.md` §2.1](docs/Reference_guide.md#21-signing-in).
## Docker (giới hạn 2 CPU, 4 GB RAM)
Mô phỏng cấu hình controller tối thiểu SICK (Dual-Core, 4 GB) trên máy dev:

76
data/auth.json Normal file
View File

@@ -0,0 +1,76 @@
{
"groups": [
{
"allow_pin": false,
"id": "group_distributors",
"name": "Distributors",
"permissions": {
"config": "write",
"dashboard": "write",
"integrations": "write",
"missions": "write",
"users": "write"
}
},
{
"allow_pin": false,
"id": "group_administrators",
"name": "Administrators",
"permissions": {
"config": "write",
"dashboard": "write",
"integrations": "write",
"missions": "write",
"users": "write"
}
},
{
"allow_pin": true,
"id": "group_users",
"name": "Users",
"permissions": {
"config": "read",
"dashboard": "write",
"integrations": "read",
"missions": "read",
"users": "none"
}
}
],
"users": [
{
"display_name": "Distributor",
"enabled": true,
"group_id": "group_distributors",
"id": "user_distributor",
"password_hash": "e245409d2efb801adfb55abc4f8298deff27e86d9c3ca11a05e1403de3d4cc44",
"password_salt": "9c23467cf7b338b6cd27dab6f411135a",
"pin_hash": null,
"pin_salt": null,
"username": "Distributor"
},
{
"display_name": "Administrator",
"enabled": true,
"group_id": "group_administrators",
"id": "user_admin",
"password_hash": "d07eb95a7364e6fb9fe2ce152e3617dc0f23bb943263c5ca2f77a4cbbf5d5396",
"password_salt": "804fec3b7b4910d6bdde1fb3782371e5",
"pin_hash": null,
"pin_salt": null,
"username": "Admin"
},
{
"display_name": "Operator",
"enabled": true,
"group_id": "group_users",
"id": "user_operator",
"password_hash": "b9091e9f6bcbd060231cc2f2e0ae028af88db0bca2af068548cb7604329fbdc9",
"password_salt": "d2eedd0b0d2446af5ba875ebcff658f1",
"pin_hash": "8dd8a6d52c7b7b76fde819aae2d5d3e3e06b321f71f61ef2918be879ace49d71",
"pin_salt": "8d0ec0ed4339dafcb0f099a4c77895a2",
"username": "User"
}
],
"version": 1
}

BIN
docs/Reference guide.pdf Normal file

Binary file not shown.

446
docs/Reference_guide.md Normal file
View File

@@ -0,0 +1,446 @@
# MiR Robot Reference Guide — Tóm tắt
> Nguồn: `docs/Reference guide.pdf`
> **MiR robot Reference guide (en), rev. 1.9, 03/2019**
> Mô tả giao diện web trên robot MiR (không phải User Guide phần cứng MiR250).
---
## 1. Giới thiệu
Tài liệu dành cho **administrator** và người cấu hình hệ thống: tạo mission, map, user, dashboard, Modbus trigger.
Tài liệu liên quan khác (Distributor site / Support Portal):
| Loại | Nội dung |
|------|----------|
| Quick Start | Vận hành nhanh (in trong hộp robot) |
| User Guide | Vận hành & bảo trì robot (MiR250 có bản riêng) |
| Commissioning / Risk Analysis | Đưa robot vào sản xuất an toàn |
| REST API Reference | Robot, Hook, Fleet |
| Network & WiFi Guide | Yêu cầu mạng |
- Fleet (scheduler, robot groups): tài liệu riêng *MiR Fleet Reference Guide*.
---
## 2. MiR robot interface (ch. 2)
Giao diện web trên robot: **responsive** (PC, tablet, portrait/landscape). Truy cập qua WiFi AP robot hoặc LAN (`http://<robot_ip>` / `mir.com`).
### 2.1 Signing in
> **Test3:** tính năng đã triển khai — xem [Test3 — Signing in](#test3--signing-in-đã-triển-khai).
#### Luồng truy cập (MiR)
```
Thiết bị → kết nối mạng robot → trình duyệt → trang Sign in → shell app (Dashboard / Setup / …)
```
Toàn bộ UI bị chặn cho đến khi đăng nhập thành công.
#### Hai cách đăng nhập
| Cách | Đối tượng | Giao diện |
|------|-----------|-----------|
| **Username + password** | Distributor, Administrator, kỹ sư | Tab form username + password |
| **PIN 4 chữ số** | Operator sàn (quyền thấp) | Tab PIN; **không có PIN mặc định** |
#### Ba cấp truy cập mặc định
| Role | Username | Password mặc định | Vai trò |
|------|----------|-------------------|---------|
| **Distributor** | `Distributor` | Liên hệ MiR Support | Đại lý; full quyền; quản lý quyền Admin/User |
| **Administrator** | `Admin` | `admin` | Kỹ sư khách hàng; full read/write |
| **User** | `User` | `user` | Operator hàng ngày |
> *MiR250 Quick Start:* đổi password mặc định ngay; mỗi người một tài khoản; chỉ operator cấp thấp dùng PIN; Admin/Distributor dùng password mạnh.
#### Tách credentials và permissions
| Lớp | Gắn với | Nội dung |
|-----|---------|----------|
| **Credentials** | **User** (cá nhân) | username, password, PIN |
| **Permissions** | **User group** (nhóm) | module nào được xem / sửa |
Mỗi user thuộc **một** user group. Mục không có quyền write: **vẫn hiển thị nhưng không chỉnh sửa được**.
#### User groups mặc định (mục 4.6)
| Nhóm | Quyền mặc định |
|------|----------------|
| **Distributors** | Full R/W; quản lý quyền Administrators và Users |
| **Administrators** | Full R/W; quản lý quyền Users |
| **Users** | Xem toàn UI; tạo/sửa **dashboard** |
Admin có thể tạo thêm user group (ví dụ `Operators`) và gán quyền từng module (Maps, Missions, System…).
#### Sau khi đăng nhập
- **Góc phải trên:** tên user → đổi password, Sign out.
- Admin tạo user tại **Setup → Users**; nhóm tại **Setup → User groups** (tạo group **trước** user).
- Dashboard gán quyền theo group qua nút **Permissions** khi tạo/sửa dashboard.
- Widget **Log-out button** trên dashboard (hữu ích trên tablet).
#### Bảo mật (Quick Start + SW mới)
- MiR **không** ép password policy phức tạp trên robot đơn lẻ.
- Không nên nhiều người dùng chung một account.
- SW mới (~2023): **auto sign-out** theo user group; MiR Fleet hỗ trợ **OAuth 2.0 / OpenID Connect**.
#### Test3 — Signing in (đã triển khai)
Tính năng đăng nhập theo MiR §2.1 đã tích hợp vào `lidar_manager_web`. Toàn bộ API (trừ health/login/logout) yêu cầu session; UI bị chặn cho đến khi đăng nhập thành công.
##### Luồng người dùng
```
Trình duyệt → / (trang Sign in)
→ POST /api/auth/login (password hoặc PIN)
→ Cookie lm_session + shell app (Dashboard / Cấu hình / Missions / Tích hợp)
→ Menu user (góc phải): đổi mật khẩu, đăng xuất
```
- Static (`www/`) phục vụ công khai để tải trang login.
- `auth.js` gọi `GET /api/auth/me` khi mở trang; session hợp lệ thì vào app ngay.
- `app.js`, `missions.js`, `dashboard.js`, `integrations.js` chỉ khởi động sau sự kiện `lm:auth-ready`.
- API mission queue và các endpoint khác **không** được gọi trước khi đăng nhập.
##### Giao diện web (MiR-style)
| Thành phần | Mô tả |
|------------|--------|
| Nền | Xanh `#3d6cb3`, full-screen |
| Header | Tên robot (`RobotApp`) + «Chọn cách đăng nhập» + 2 tab |
| Tab **Tên đăng nhập và mật khẩu** | 2 cột: hướng dẫn trái, form phải; nút xanh «Đăng nhập» |
| Tab **Mã PIN** | Trái: hướng dẫn + 4 ô vuông (•); phải: keypad 19, 0, ✕ |
| PIN | Tự đăng nhập khi đủ 4 số; hỗ trợ bàn phím vật lý |
| Sau login | Menu user topbar; ẩn/vô hiệu menu theo quyền read-only |
File: `www/index.html`, `www/auth.js`, `www/style.css`.
##### Tài khoản mặc định
Tự tạo lần đầu trong `data/auth.json` (cùng thư mục `state.json`):
| Username | Password | User group | Ghi chú |
|----------|----------|------------|---------|
| `Admin` | `admin` | Administrators | Full quyền |
| `User` | `user` | Users | Dashboard write; phần còn lại read |
| `Distributor` | `distributor` | Distributors | Full quyền |
- Username đăng nhập **không phân biệt hoa thường** (`admin` = `Admin`).
- **PIN:** không có mã mặc định (giống MiR). Chỉ nhóm **Users** (`allow_pin: true`); admin gán qua API.
##### User groups và permissions
Credentials → **user**; quyền → **group**. Module: `dashboard`, `config`, `missions`, `integrations`, `users` — giá trị `none` | `read` | `write`.
| Group | PIN | dashboard | config | missions | integrations | users |
|-------|-----|-----------|--------|----------|--------------|-------|
| Distributors | Không | write | write | write | write | write |
| Administrators | Không | write | write | write | write | write |
| Users | Sau khi gán | write | read | read | read | none |
| Group | Menu UI |
|-------|---------|
| Users | Dashboard + xem Cấu hình/Missions/Tích hợp (nút ghi read-only) |
| Administrators / Distributors | Toàn bộ menu; quản lý user qua API |
##### Session và middleware
| Cơ chế | Chi tiết |
|--------|----------|
| Session | Server-side; mất khi restart process |
| Cookie | `lm_session=<token>; HttpOnly; SameSite=Lax` |
| Header | `Authorization: Bearer <token>` |
| Middleware | `AuthService::preRoute` trên `/api/*` |
| Public | `GET /api/health`, `POST /api/auth/login`, `POST /api/auth/logout`, `OPTIONS` |
| Dev | `LM_AUTH_DISABLED=1` tắt auth |
**API → module** (kiểm tra read/write):
| Module | Prefix |
|--------|--------|
| config | `/api/lidars`, `/api/imus`, `/api/layouts`, `/api/state`, … |
| missions | `/api/missions`, `/api/mission_queue` |
| integrations | `/api/triggers`, `/api/schedules`, `/api/fleet`, `/api/modbus`, `/api/v2.0.0/` |
| users | `/api/users`, `/api/user_groups` |
##### REST API
| Method | Endpoint | Auth | Mô tả |
|--------|----------|------|--------|
| POST | `/api/auth/login` | Public | `{ username, password }` hoặc `{ pin }` |
| POST | `/api/auth/logout` | Public | Xóa session + cookie |
| GET | `/api/auth/me` | Session | User, group, permissions |
| PUT | `/api/auth/password` | Session | Đổi mật khẩu |
| GET | `/api/user_groups` | users read | Danh sách nhóm |
| GET | `/api/users` | users read | Danh sách user |
| POST | `/api/users` | users write | Tạo user |
| PUT | `/api/users/:id` | users write | Sửa user / gán PIN (`pin: null` = xóa) |
| DELETE | `/api/users/:id` | users write | Xóa user |
**Ví dụ login + gán PIN**
```bash
curl -c c.txt -X POST http://localhost:8080/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"Admin","password":"admin"}'
curl -b c.txt -X PUT http://localhost:8080/api/users/user_operator \
-H 'Content-Type: application/json' \
-d '{"pin":"1234"}'
```
##### Lưu trữ và mã nguồn
| Thành phần | Vị trí |
|------------|--------|
| Dữ liệu | `data/auth.json` — groups, users (hash + salt) |
| Backend | `src/auth/auth_service.cpp`, `src/util/crypto_util.cpp`, `src/app/lidar_manager_app.cpp` |
| Frontend | `www/auth.js`, `www/index.html`, `www/style.css` |
| Test | `scripts/test/smoke.sh`, `tests/test_api_integration.py` |
Hash: SHA-256 + salt (`sha256(salt:password)` / `sha256(salt:pin:pin)`).
##### Kiểm thử và vận hành
```bash
./scripts/lm.sh test run # smoke tự login Admin; pytest test_auth_*
```
- Docker: `www/` copy lúc build → `docker compose up --build -d` sau sửa UI.
- Hard refresh (`Ctrl+Shift+R`) nếu cache JS/CSS.
##### So sánh MiR ↔ Test3
| MiR §2.1 | Test3 |
|----------|-------|
| Sign in bắt buộc | Có |
| Tab password \| PIN + keypad | Có |
| 3 role mặc định | Admin / User / Distributor |
| PIN không mặc định | Có — admin gán API |
| User menu, đổi password, sign out | Có |
| Credentials / permissions tách biệt | Có |
| Setup → Users (UI) | Chưa — chỉ API |
| Auto sign-out / OAuth Fleet | Chưa |
### 2.2 Navigating the MiR robot interface
- Menu chính → **tối đa một submenu** rồi vào section (ví dụ Setup → Sounds).
- **Top bar:** trạng thái robot, nút start/pause.
### 2.3 Getting started
Thiết lập user trước vận hành:
1. **Users** (Setup → Users)
2. **User groups** (Setup → User groups)
3. **Dashboards** theo vai trò (Dashboards → Create)
Thiết lập hệ thống: map → chỉnh map (positions, zones) → missions.
---
## 3. Mục lục đầy đủ
### 1. About this document
- 1.1. Where to find more information
- 1.2. Document history
### 2. MiR robot interface
- 2.1. Signing in
- 2.2. Navigating the MiR robot interface
- 2.3. Getting started
### 3. Dashboards
- **3.1. Dashboards** — Create / designer / edit / delete
- **3.2. Widgets**
- 3.2.1. **Maps** — Locked map, Map
- 3.2.2. **Missions** — Mission button, Pause/Continue, Mission queue, Mission action log, Mission group
- 3.2.3. **PLC registers** — PLC button/display
- 3.2.4. **I/O module** — Connect, configuration, status
- 3.2.5. **Hook** — Cart actions (Pick up / Place cart)
- 3.2.6. **Miscellaneous** — Joystick, Robot summary, Distributor, Log-out button
### 4. Setup
- **4.1. Missions** — Start, Create, Editor, Actions (xem §4 bên dưới)
- **4.2. Maps** — Site, mapping, object types (walls, zones…), delete
- **4.3. Sounds** — Edit sound
- **4.4. Transitions** — Chuyển map tự động
- **4.5. Users** — CRUD user
- **4.6. User groups** — CRUD nhóm
- **4.7. Shelf types**
- **4.8. I/O modules** — Kết nối Modbus/TCP I/O
- **4.9. Paths**
- **4.10. Path guides**
### 5. Monitoring
- Analytics, System log, Error logs, Hardware health, Safety system
- **Mission log** / Mission action log
### 6. System
- Settings (WiFi, date/time), Processes, PLC registers, Software versions, Backups
- **Robot setup** — bật Modbus, cấu hình robot
- **Triggers** — gắn coil Modbus → mission_id
### 7. Help
- Robot & Hook info, **API documentation**, Remote access, Service book, Manual
### 8. Hook
- Manual control, Carts (type, calibration), Settings, Setup
### 9. Modbus register reference
- 9.1. Status messages (registers 4001+)
- 9.2. PLC triggers (int/float)
- 9.3. Mission triggers (coil **10012000**)
- 9.4. Action commands (coil **16**)
---
## 4. Ba cách chạy Mission (mục 4.1.1)
| Cách | Mô tả |
|------|--------|
| **Dashboard** | Widget Mission button — một mission cố định |
| **Setup → Missions** | Bấm icon **queue** → thêm vào mission queue |
| **Tích hợp ngoài** | Modbus trigger (coil), REST API (xem Help → API) |
**Mission queue:** robot chạy tuần tự từ trên xuống; operator có thể sắp xếp lại.
**Biến (variables):** nếu mission có tham số biến (ví dụ position), operator chọn giá trị khi enqueue — hiển thị **màu xanh** trong queue.
---
## 5. Mission editor
- Mission = chuỗi **actions** (Move, Logic, Battery, I/O, Cart…).
- Action có thể dùng **giá trị cố định** hoặc **biến** (hỏi operator mỗi lần enqueue).
- Kéo thả ↕ để sắp xếp; thực thi **từ trên xuống dưới**.
- Có thể **embed mission con** (icon ◎) trong mission lớn.
- **Save** / **Save as** / đổi tên & nhóm qua ⚙.
### 4.1.4. Mission actions — các nhóm
#### Variables (4.1.4.12)
- Khai báo biến dùng chung trong mission.
#### Move (4.1.4.3)
| Action | Mục đích |
|--------|----------|
| Adjust localization | Hiệu chỉnh vị trí trên map |
| Check position status | Kiểm tra position free/occupied (timeout) |
| Docking | Dock vào marker / trạm sạc |
| Move | Đi tới position (retries, distance threshold) |
| Move to entry position | Đi tới entry position trước khi dock/pick |
| Move to coordinate | X, Y, orientation tuyệt đối trên map |
| Planner settings | Desired speed, path deviation, path timeout |
| Relative Move | Dịch chuyển tương đối X/Y/yaw |
| Set footprint | Đổi footprint (top module, cart) |
| Switch Map | Chuyển map trong mission (cần overlap vật lý) |
#### Battery (4.1.4.4)
- **Charging** — đi dock + sạc theo thời gian tối thiểu hoặc % pin; có thể giữ sạc đến khi có mission mới.
#### Logic (4.1.4.5)
| Action | Mục đích |
|--------|----------|
| **Break** | Thoát vòng **Loop** |
| **Continue** | Bỏ phần còn lại của vòng loop, sang vòng tiếp theo |
| **If** | Điều kiện: pin %, pending missions, PLC register, I/O input → nhánh True/False |
| **Loop** | Lặp N lần hoặc **endlessly** (đến khi operator dừng); kéo action vào body loop |
| **Pause** | Dừng mission đến khi operator bấm Continue |
| **Prompt User** | Hỏi Yes/No/Timeout |
| **Return** | **Abort mission** (thường trong Try/Catch) |
| **Wait** | Chờ N giây |
| **While** | Lặp action khi điều kiện còn đúng |
#### Error handling (4.1.4.6)
- **Try/Catch** — Try thất bại → chạy Catch (ví dụ Return).
#### Sound/Light, PLC, Email, I/O module, Cart, Shelf, UR
- Set/wait I/O, PLC register, pick/drop cart, shelf, tích hợp UR cobot, v.v.
---
## 6. Maps
- **Site** — nhóm nhiều map (tầng/khu vực); robot chuyển map qua Transition hoặc Switch Map action.
- **Object types:** Walls, Floors, Positions, Markers, Directional zones, Preferred/Unpreferred/Forbidden/Critical zones, Speed zones, Sound/light zones, Planner zones, I/O zones, Limit-robots (Fleet), Evacuation zones (Fleet).
---
## 7. Dashboard widgets
| Widget MiR | Test3 (Cách B) |
|------------|----------------|
| Mission button | `dashboard.js` — mission_button |
| Mission group | mission_group |
| Mission queue | mission_queue |
| Pause/Continue | pause_continue (+ **Hủy mission** bổ sung trong Test3) |
---
## 8. Modbus
Robot là **Modbus TCP server**. Bật tại **System → Robot setup**, cấu hình trigger tại **System → Triggers**.
### Mission triggers (coil 10012000)
Rising edge coil → enqueue mission đã gắn `mission_id`.
### Action commands (coil 16)
| Coil | Chức năng |
|------|-----------|
| 1 | Continue robot |
| 2 | Pause robot |
| 3 | **Cancel current mission** |
| 4 | Clear mission queue |
| 5 | Clear error |
| 6 | Continue robot |
### Status registers (ví dụ)
Software version, mode, state, error code, battery %, uptime… (registers 4001+).
---
## 9. REST API
Tài liệu API đầy đủ: **Help → API documentation → Launch** trên giao diện robot.
Base URL: `http://<robot_ip>/api/v2.0.0/`
| Endpoint | Mô tả |
|----------|--------|
| `GET /status` | Trạng thái robot |
| `GET /missions` | Danh sách mission |
| `GET /mission_queue` | Queue hiện tại |
| `POST /mission_queue` | Enqueue (`mission_id`) |
| `DELETE /mission_queue` | Xóa queue |
Xác thực: HTTP Basic (user/password robot).
---
## 10. Mapping sang dự án Test3
| Khái niệm MiR Reference Guide | Test3 |
|------------------------------|--------|
| Setup → Missions → queue | **Cách A**`www/missions.js` |
| Dashboard widgets | **Cách B**`www/dashboard.js` |
| Modbus triggers 10012000 | **Cách C**`:5502`, `integrations.js` |
| REST v2 mission_queue | `POST /api/v2.0.0/mission_queue` |
| MiR Fleet schedule | `/api/fleet/schedules` |
| Loop / Break / Continue | `www/missions.js` + `mission_queue.cpp` |
| Pause / Continue | `/api/mission_queue/pause`, `/continue` |
| Cancel (Modbus coil 3) | `/api/mission_queue/cancel` |
| Sign in / User groups | **Đã triển khai** — §2.1 (`AuthService`, UI MiR, `data/auth.json`) |
---
## 11. Ghi chú
- Rev. 1.9 (2019) — firmware mới có thể khác; đối chiếu bản API trên robot thực tế.
- Phần cứng MiR250: xem `docs/mir250_user_guide_11_en.pdf`.
- Fleet (scheduler, robot groups): tài liệu riêng *MiR Fleet Reference Guide*.

Binary file not shown.

View File

@@ -30,7 +30,7 @@ PY
}
http_code() {
curl -s -o "$1" -w '%{http_code}' "${@:2}"
curl -s -o "$1" -w '%{http_code}' "${CURL_OPTS[@]}" "${@:2}"
}
assert_code() {
@@ -67,15 +67,37 @@ PY
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
COOKIE_JAR="$TMP/cookies.txt"
CURL_OPTS=()
echo "API smoke tests → $BASE"
echo
# --- Health & static ---
# --- Health & static (no auth) ---
assert_code "GET /api/health" 200 "$TMP/health.json" -X GET "$BASE/api/health"
assert_json_true "health ok" "$TMP/health.json" 'doc.get("ok") is True'
assert_code "GET /api/state without auth" 401 "$TMP/state_unauth.json" -X GET "$BASE/api/state"
assert_code "POST /api/auth/login bad password" 401 "$TMP/login_bad.json" \
-X POST "$BASE/api/auth/login" \
-H 'Content-Type: application/json' \
-d '{"username":"Admin","password":"wrong"}'
assert_code "POST /api/auth/login" 200 "$TMP/login.json" \
-X POST "$BASE/api/auth/login" \
-H 'Content-Type: application/json' \
-c "$COOKIE_JAR" \
-d '{"username":"Admin","password":"admin"}'
assert_json_true "login user" "$TMP/login.json" 'doc.get("user",{}).get("username") == "Admin"'
CURL_OPTS=(-b "$COOKIE_JAR" -c "$COOKIE_JAR")
assert_code "GET /api/auth/me" 200 "$TMP/me.json" -X GET "$BASE/api/auth/me"
assert_json_true "auth me" "$TMP/me.json" 'doc.get("user",{}).get("group_name") == "Administrators"'
assert_code "GET /" 200 "$TMP/index.html" -X GET "$BASE/"
assert_code "GET /auth.js" 200 "$TMP/auth.js" -X GET "$BASE/auth.js"
assert_code "GET /missions.js" 200 "$TMP/missions.js" -X GET "$BASE/missions.js"
assert_code "GET /api/state" 200 "$TMP/state.json" -X GET "$BASE/api/state"
@@ -107,12 +129,12 @@ echo
assert_json_true "missions available" "$TMP/missions.json" 'len(doc.get("missions", [])) >= 1'
# --- Queue pause/continue (chạy sớm, trước các test enqueue khác) ---
curl -s -X DELETE "$BASE/api/mission_queue" -o /dev/null || true
curl -s -X POST "$BASE/api/mission_queue" \
curl -s "${CURL_OPTS[@]}" -X DELETE "$BASE/api/mission_queue" -o /dev/null || true
curl -s "${CURL_OPTS[@]}" -X POST "$BASE/api/mission_queue" \
-H 'Content-Type: application/json' \
-d "{\"mission_id\":\"$MISSION_ID\"}" -o "$TMP/qpost.json"
for _ in $(seq 1 30); do
curl -s "$BASE/api/mission_queue" -o "$TMP/runner_poll.json"
curl -s "${CURL_OPTS[@]}" "$BASE/api/mission_queue" -o "$TMP/runner_poll.json"
RUNNER_STATE="$(python3 -c "import json; print(json.load(open('$TMP/runner_poll.json')).get('runner',{}).get('state',''))")"
if [[ "$RUNNER_STATE" == "running" ]]; then
break
@@ -127,7 +149,7 @@ if [[ "$RUNNER_STATE" == "running" ]]; then
-X POST "$BASE/api/mission_queue/continue"
assert_json_true "runner not paused" "$TMP/cont.json" 'doc.get("state") != "paused"'
for _ in $(seq 1 15); do
curl -s "$BASE/api/mission_queue" -o "$TMP/runner_poll.json"
curl -s "${CURL_OPTS[@]}" "$BASE/api/mission_queue" -o "$TMP/runner_poll.json"
RUNNER_STATE="$(python3 -c "import json; print(json.load(open('$TMP/runner_poll.json')).get('runner',{}).get('state',''))")"
if [[ "$RUNNER_STATE" == "running" || "$RUNNER_STATE" == "paused" ]]; then
break
@@ -138,7 +160,7 @@ if [[ "$RUNNER_STATE" == "running" ]]; then
assert_code "POST /api/mission_queue/cancel" 200 "$TMP/cancel.json" \
-X POST "$BASE/api/mission_queue/cancel"
for _ in $(seq 1 40); do
curl -s "$BASE/api/mission_queue" -o "$TMP/runner_poll.json"
curl -s "${CURL_OPTS[@]}" "$BASE/api/mission_queue" -o "$TMP/runner_poll.json"
RUNNER_STATE="$(python3 -c "import json; print(json.load(open('$TMP/runner_poll.json')).get('runner',{}).get('state',''))")"
if [[ "$RUNNER_STATE" == "idle" ]]; then
break
@@ -191,8 +213,8 @@ else
fi
# --- Clear queue ---
curl -s -X DELETE "$BASE/api/mission_queue" -o /dev/null || true
curl -s -X DELETE "$BASE/api/v2.0.0/mission_queue" -o /dev/null || true
curl -s "${CURL_OPTS[@]}" -X DELETE "$BASE/api/mission_queue" -o /dev/null || true
curl -s "${CURL_OPTS[@]}" -X DELETE "$BASE/api/v2.0.0/mission_queue" -o /dev/null || true
# --- MiR v2 enqueue (Cách C REST) ---
assert_code "POST /api/v2.0.0/mission_queue" 201 "$TMP/v2q.json" \
@@ -204,7 +226,7 @@ assert_json_true "v2 queue entry mission_id" "$TMP/v2q.json" \
assert_code "GET /api/v2.0.0/mission_queue" 200 "$TMP/v2list.json" -X GET "$BASE/api/v2.0.0/mission_queue"
for _ in $(seq 1 15); do
curl -s "$BASE/api/v2.0.0/mission_queue" -o "$TMP/v2list.json"
curl -s "${CURL_OPTS[@]}" "$BASE/api/v2.0.0/mission_queue" -o "$TMP/v2list.json"
if python3 -c "import json; d=json.load(open('$TMP/v2list.json')); exit(0 if isinstance(d,list) and len(d)>=1 else 1)" 2>/dev/null; then
break
fi

View File

@@ -1,5 +1,6 @@
#include "app/lidar_manager_app.hpp"
#include "auth/auth_service.hpp"
#include "mission/mission_enqueue.hpp"
#include "mission/mission_queue.hpp"
#include "mission/mission_scheduler.hpp"
@@ -41,9 +42,16 @@ int LidarManagerApp::run()
ModbusTriggerService modbus(mission_store, enqueue_fn, 5502);
MissionScheduler scheduler(mission_store, enqueue_fn);
AuthService auth(data_path_.parent_path() / "auth.json");
httplib::Server svr;
svr.set_pre_routing_handler([&auth](const httplib::Request& req, httplib::Response& res) {
return auth.preRoute(req, res);
});
ApiServer api(repo, mission_queue, mission_store, modbus, scheduler);
api.registerRoutes(svr);
auth.registerRoutes(svr);
StaticFileServer::mount(svr, www_root_);
std::fprintf(stderr,

776
src/auth/auth_service.cpp Normal file
View File

@@ -0,0 +1,776 @@
#include "auth/auth_service.hpp"
#include "util/crypto_util.hpp"
#include "util/file_util.hpp"
#include "util/http_util.hpp"
#include "util/id_util.hpp"
#include "util/string_util.hpp"
#include <algorithm>
#include <cstdlib>
#include <ctime>
namespace lm {
thread_local const AuthSession* AuthService::tls_session_ = nullptr;
namespace {
constexpr const char* kSessionCookie = "lm_session";
nlohmann::json defaultPermissionsAllWrite()
{
return {{"dashboard", "write"},
{"config", "write"},
{"missions", "write"},
{"integrations", "write"},
{"users", "write"}};
}
nlohmann::json defaultPermissionsUserGroup()
{
return {{"dashboard", "write"},
{"config", "read"},
{"missions", "read"},
{"integrations", "read"},
{"users", "none"}};
}
nlohmann::json makeUser(const std::string& id,
const std::string& username,
const std::string& password,
const std::string& group_id,
const std::string& display_name)
{
const std::string salt = CryptoUtil::randomToken(16);
return {{"id", id},
{"username", username},
{"display_name", display_name},
{"group_id", group_id},
{"password_salt", salt},
{"password_hash", CryptoUtil::hashPassword(salt, password)},
{"pin_salt", nullptr},
{"pin_hash", nullptr},
{"enabled", true}};
}
} // namespace
AuthService::AuthService(std::filesystem::path store_path) : store_path_(std::move(store_path))
{
loadOrSeed();
}
void AuthService::loadOrSeed()
{
std::lock_guard<std::mutex> lock(mu_);
data_ = nlohmann::json::object();
if (std::filesystem::exists(store_path_))
{
try
{
data_ = nlohmann::json::parse(FileUtil::readBinary(store_path_));
}
catch (...)
{
data_ = nlohmann::json::object();
}
}
if (!data_.contains("version"))
data_["version"] = 1;
if (!data_.contains("groups") || !data_["groups"].is_array() || data_["groups"].empty())
{
data_["groups"] = nlohmann::json::array({
{{"id", "group_distributors"},
{"name", "Distributors"},
{"allow_pin", false},
{"permissions", defaultPermissionsAllWrite()}},
{{"id", "group_administrators"},
{"name", "Administrators"},
{"allow_pin", false},
{"permissions", defaultPermissionsAllWrite()}},
{{"id", "group_users"},
{"name", "Users"},
{"allow_pin", true},
{"permissions", defaultPermissionsUserGroup()}},
});
}
if (!data_.contains("users") || !data_["users"].is_array() || data_["users"].empty())
{
data_["users"] = nlohmann::json::array({
makeUser("user_distributor", "Distributor", "distributor", "group_distributors", "Distributor"),
makeUser("user_admin", "Admin", "admin", "group_administrators", "Administrator"),
makeUser("user_operator", "User", "user", "group_users", "Operator"),
});
}
saveUnlocked();
}
void AuthService::saveUnlocked()
{
const auto parent = store_path_.parent_path();
if (!parent.empty())
std::filesystem::create_directories(parent);
FileUtil::writeBinaryAtomic(store_path_, data_.dump(2));
}
const AuthSession* AuthService::currentSession() const
{
return tls_session_;
}
std::string AuthService::extractToken(const httplib::Request& req) const
{
if (req.has_header("Cookie"))
{
const std::string cookie = req.get_header_value("Cookie");
const std::string prefix = std::string(kSessionCookie) + "=";
const auto pos = cookie.find(prefix);
if (pos != std::string::npos)
{
const auto start = pos + prefix.size();
const auto end = cookie.find(';', start);
return cookie.substr(start, end == std::string::npos ? std::string::npos : end - start);
}
}
if (req.has_header("Authorization"))
{
const std::string auth = req.get_header_value("Authorization");
constexpr const char* kBearer = "Bearer ";
if (auth.rfind(kBearer, 0) == 0)
return auth.substr(std::strlen(kBearer));
}
return {};
}
bool AuthService::isPublicApiPath(const std::string& path, const std::string& method)
{
if (method == "OPTIONS")
return true;
return path == "/api/health" || path == "/api/auth/login" || path == "/api/auth/logout";
}
std::optional<std::string> AuthService::resourceForApiPath(const std::string& path)
{
if (path.rfind("/api/auth/", 0) == 0)
return std::nullopt;
if (path.rfind("/api/users", 0) == 0 || path.rfind("/api/user_groups", 0) == 0)
return "users";
if (path.rfind("/api/missions", 0) == 0 || path.rfind("/api/mission_queue", 0) == 0)
return "missions";
if (path.rfind("/api/triggers", 0) == 0 || path.rfind("/api/schedules", 0) == 0 ||
path.rfind("/api/robots", 0) == 0 || path.rfind("/api/fleet", 0) == 0 ||
path.rfind("/api/modbus", 0) == 0 || path.rfind("/api/v2.0.0/", 0) == 0)
return "integrations";
if (path.rfind("/api/", 0) == 0)
return "config";
return std::nullopt;
}
bool AuthService::requiresWrite(const std::string& method)
{
return method != "GET" && method != "HEAD" && method != "OPTIONS";
}
bool AuthService::permissionAllows(const nlohmann::json& perms,
const std::string& resource,
bool write) const
{
if (!perms.is_object() || !perms.contains(resource))
return false;
const std::string level = perms.value(resource, "none");
if (level == "none")
return false;
if (write)
return level == "write";
return level == "read" || level == "write";
}
const nlohmann::json* AuthService::findUserByIdUnlocked(const std::string& id) const
{
if (!data_.contains("users") || !data_["users"].is_array())
return nullptr;
for (const auto& u : data_["users"])
{
if (u.value("id", "") == id)
return &u;
}
return nullptr;
}
const nlohmann::json* AuthService::findUserByUsernameUnlocked(const std::string& username) const
{
if (!data_.contains("users") || !data_["users"].is_array())
return nullptr;
const std::string needle = StringUtil::toLower(StringUtil::trimCopy(username));
for (const auto& u : data_["users"])
{
if (StringUtil::toLower(u.value("username", "")) == needle)
return &u;
}
return nullptr;
}
const nlohmann::json* AuthService::findGroupByIdUnlocked(const std::string& id) const
{
if (!data_.contains("groups") || !data_["groups"].is_array())
return nullptr;
for (const auto& g : data_["groups"])
{
if (g.value("id", "") == id)
return &g;
}
return nullptr;
}
bool AuthService::verifyPasswordUnlocked(const nlohmann::json& user, const std::string& password) const
{
const std::string salt = user.value("password_salt", "");
const std::string hash = user.value("password_hash", "");
if (salt.empty() || hash.empty())
return false;
return CryptoUtil::hashPassword(salt, password) == hash;
}
bool AuthService::verifyPinUnlocked(const nlohmann::json& user, const std::string& pin) const
{
if (pin.size() != 4 || !std::all_of(pin.begin(), pin.end(), ::isdigit))
return false;
if (user.value("pin_hash", nlohmann::json()).is_null())
return false;
const std::string salt = user.value("pin_salt", "");
const std::string hash = user.value("pin_hash", "");
if (salt.empty() || hash.empty())
return false;
return CryptoUtil::hashPin(salt, pin) == hash;
}
bool AuthService::groupAllowsPinUnlocked(const std::string& group_id) const
{
const auto* group = findGroupByIdUnlocked(group_id);
return group && group->value("allow_pin", false);
}
std::optional<AuthSession> AuthService::buildSessionUnlocked(const nlohmann::json& user)
{
const auto* group = findGroupByIdUnlocked(user.value("group_id", ""));
if (!group)
return std::nullopt;
AuthSession session;
session.token = CryptoUtil::randomToken(32);
session.user_id = user.value("id", "");
session.username = user.value("username", "");
session.group_id = group->value("id", "");
session.group_name = group->value("name", "");
session.permissions = group->value("permissions", nlohmann::json::object());
return session;
}
nlohmann::json AuthService::userPublicView(const nlohmann::json& user, const nlohmann::json& group)
{
return {{"id", user.value("id", "")},
{"username", user.value("username", "")},
{"display_name", user.value("display_name", "")},
{"group_id", user.value("group_id", "")},
{"group_name", group.value("name", "")},
{"permissions", group.value("permissions", nlohmann::json::object())},
{"has_pin", !user.value("pin_hash", nlohmann::json()).is_null()}};
}
std::optional<nlohmann::json> AuthService::loginPassword(const std::string& username,
const std::string& password,
std::string& err)
{
std::lock_guard<std::mutex> lock(mu_);
const auto* user = findUserByUsernameUnlocked(username);
if (!user || !user->value("enabled", true))
{
err = "invalid credentials";
return std::nullopt;
}
if (!verifyPasswordUnlocked(*user, password))
{
err = "invalid credentials";
return std::nullopt;
}
auto session = buildSessionUnlocked(*user);
if (!session)
{
err = "invalid user group";
return std::nullopt;
}
sessions_[session->token] = *session;
const auto* group = findGroupByIdUnlocked(user->value("group_id", ""));
return nlohmann::json{{"token", session->token},
{"user", userPublicView(*user, group ? *group : nlohmann::json::object())}};
}
std::optional<nlohmann::json> AuthService::loginPin(const std::string& pin, std::string& err)
{
std::lock_guard<std::mutex> lock(mu_);
if (pin.size() != 4 || !std::all_of(pin.begin(), pin.end(), ::isdigit))
{
err = "pin must be 4 digits";
return std::nullopt;
}
const nlohmann::json* matched = nullptr;
for (const auto& user : data_["users"])
{
if (!user.value("enabled", true))
continue;
if (!groupAllowsPinUnlocked(user.value("group_id", "")))
continue;
if (verifyPinUnlocked(user, pin))
{
matched = &user;
break;
}
}
if (!matched)
{
err = "invalid pin";
return std::nullopt;
}
auto session = buildSessionUnlocked(*matched);
if (!session)
{
err = "invalid user group";
return std::nullopt;
}
sessions_[session->token] = *session;
const auto* group = findGroupByIdUnlocked(matched->value("group_id", ""));
return nlohmann::json{{"token", session->token},
{"user", userPublicView(*matched, group ? *group : nlohmann::json::object())}};
}
bool AuthService::logout(const std::string& token)
{
if (token.empty())
return false;
std::lock_guard<std::mutex> lock(mu_);
return sessions_.erase(token) > 0;
}
std::optional<nlohmann::json> AuthService::sessionInfo(const std::string& token) const
{
if (token.empty())
return std::nullopt;
std::lock_guard<std::mutex> lock(mu_);
const auto it = sessions_.find(token);
if (it == sessions_.end())
return std::nullopt;
const auto* user = findUserByIdUnlocked(it->second.user_id);
if (!user)
return std::nullopt;
const auto* group = findGroupByIdUnlocked(user->value("group_id", ""));
return userPublicView(*user, group ? *group : nlohmann::json::object());
}
bool AuthService::changePassword(const std::string& token,
const std::string& current_password,
const std::string& new_password,
std::string& err)
{
if (new_password.size() < 4)
{
err = "password too short";
return false;
}
std::lock_guard<std::mutex> lock(mu_);
const auto it = sessions_.find(token);
if (it == sessions_.end())
{
err = "not authenticated";
return false;
}
for (auto& user : data_["users"])
{
if (user.value("id", "") != it->second.user_id)
continue;
if (!verifyPasswordUnlocked(user, current_password))
{
err = "current password incorrect";
return false;
}
const std::string salt = CryptoUtil::randomToken(16);
user["password_salt"] = salt;
user["password_hash"] = CryptoUtil::hashPassword(salt, new_password);
saveUnlocked();
return true;
}
err = "user not found";
return false;
}
nlohmann::json AuthService::listGroups() const
{
std::lock_guard<std::mutex> lock(mu_);
nlohmann::json out = nlohmann::json::array();
for (const auto& g : data_["groups"])
{
out.push_back({{"id", g.value("id", "")},
{"name", g.value("name", "")},
{"allow_pin", g.value("allow_pin", false)},
{"permissions", g.value("permissions", nlohmann::json::object())}});
}
return out;
}
nlohmann::json AuthService::listUsers() const
{
std::lock_guard<std::mutex> lock(mu_);
nlohmann::json out = nlohmann::json::array();
for (const auto& u : data_["users"])
{
const auto* group = findGroupByIdUnlocked(u.value("group_id", ""));
out.push_back(userPublicView(u, group ? *group : nlohmann::json::object()));
}
return out;
}
std::optional<nlohmann::json> AuthService::createUser(const nlohmann::json& payload, std::string& err)
{
const std::string username = payload.value("username", "");
const std::string password = payload.value("password", "");
const std::string group_id = payload.value("group_id", "");
if (username.empty() || password.empty() || group_id.empty())
{
err = "username, password and group_id required";
return std::nullopt;
}
std::lock_guard<std::mutex> lock(mu_);
if (findUserByUsernameUnlocked(username))
{
err = "username already exists";
return std::nullopt;
}
if (!findGroupByIdUnlocked(group_id))
{
err = "unknown group";
return std::nullopt;
}
const std::string id = "user_" + IdUtil::newId();
auto user = makeUser(id, username, password, group_id, payload.value("display_name", username));
if (payload.contains("pin") && !payload["pin"].is_null())
{
const std::string pin = payload.value("pin", "");
if (pin.size() != 4 || !std::all_of(pin.begin(), pin.end(), ::isdigit))
{
err = "pin must be 4 digits";
return std::nullopt;
}
if (!groupAllowsPinUnlocked(group_id))
{
err = "group does not allow pin";
return std::nullopt;
}
const std::string pin_salt = CryptoUtil::randomToken(16);
user["pin_salt"] = pin_salt;
user["pin_hash"] = CryptoUtil::hashPin(pin_salt, pin);
}
data_["users"].push_back(user);
saveUnlocked();
const auto* group = findGroupByIdUnlocked(group_id);
return userPublicView(user, group ? *group : nlohmann::json::object());
}
std::optional<nlohmann::json> AuthService::updateUser(const std::string& id,
const nlohmann::json& payload,
std::string& err)
{
std::lock_guard<std::mutex> lock(mu_);
for (auto& user : data_["users"])
{
if (user.value("id", "") != id)
continue;
if (payload.contains("display_name"))
user["display_name"] = payload["display_name"];
if (payload.contains("enabled"))
user["enabled"] = payload["enabled"];
if (payload.contains("group_id"))
{
const std::string group_id = payload.value("group_id", "");
if (!findGroupByIdUnlocked(group_id))
{
err = "unknown group";
return std::nullopt;
}
user["group_id"] = group_id;
}
if (payload.contains("password") && payload["password"].is_string())
{
const std::string password = payload["password"];
const std::string salt = CryptoUtil::randomToken(16);
user["password_salt"] = salt;
user["password_hash"] = CryptoUtil::hashPassword(salt, password);
}
if (payload.contains("pin"))
{
if (payload["pin"].is_null())
{
user["pin_salt"] = nullptr;
user["pin_hash"] = nullptr;
}
else
{
const std::string pin = payload.value("pin", "");
if (pin.size() != 4 || !std::all_of(pin.begin(), pin.end(), ::isdigit))
{
err = "pin must be 4 digits";
return std::nullopt;
}
if (!groupAllowsPinUnlocked(user.value("group_id", "")))
{
err = "group does not allow pin";
return std::nullopt;
}
const std::string pin_salt = CryptoUtil::randomToken(16);
user["pin_salt"] = pin_salt;
user["pin_hash"] = CryptoUtil::hashPin(pin_salt, pin);
}
}
saveUnlocked();
const auto* group = findGroupByIdUnlocked(user.value("group_id", ""));
return userPublicView(user, group ? *group : nlohmann::json::object());
}
err = "user not found";
return std::nullopt;
}
bool AuthService::deleteUser(const std::string& id, std::string& err)
{
std::lock_guard<std::mutex> lock(mu_);
auto& users = data_["users"];
const auto it = std::remove_if(users.begin(), users.end(), [&](const nlohmann::json& u) {
return u.value("id", "") == id;
});
if (it == users.end())
{
err = "user not found";
return false;
}
users.erase(it, users.end());
saveUnlocked();
return true;
}
bool AuthService::authorizeApiRequest(const httplib::Request& req, httplib::Response& res)
{
if (const char* disabled = std::getenv("LM_AUTH_DISABLED"); disabled && std::string(disabled) == "1")
return true;
const std::string token = extractToken(req);
if (token.empty())
{
HttpUtil::jsonError(res, 401, "authentication required");
return false;
}
std::lock_guard<std::mutex> lock(mu_);
const auto it = sessions_.find(token);
if (it == sessions_.end())
{
HttpUtil::jsonError(res, 401, "invalid or expired session");
return false;
}
tls_session_ = &it->second;
const auto resource = resourceForApiPath(req.path);
if (!resource)
return true;
const bool write = requiresWrite(req.method);
if (!permissionAllows(it->second.permissions, *resource, write))
{
HttpUtil::jsonError(res, 403, "insufficient permissions");
return false;
}
return true;
}
httplib::Server::HandlerResponse AuthService::preRoute(const httplib::Request& req,
httplib::Response& res)
{
tls_session_ = nullptr;
if (req.path.rfind("/api/", 0) != 0)
return httplib::Server::HandlerResponse::Unhandled;
if (isPublicApiPath(req.path, req.method))
return httplib::Server::HandlerResponse::Unhandled;
if (!authorizeApiRequest(req, res))
{
HttpUtil::addCors(res);
return httplib::Server::HandlerResponse::Handled;
}
return httplib::Server::HandlerResponse::Unhandled;
}
void AuthService::registerRoutes(httplib::Server& svr)
{
svr.Post("/api/auth/login", [this](const httplib::Request& req, httplib::Response& res) {
nlohmann::json body;
try
{
body = nlohmann::json::parse(req.body);
}
catch (...)
{
HttpUtil::jsonError(res, 400, "invalid json");
return;
}
std::string err;
std::optional<nlohmann::json> result;
if (body.contains("pin"))
result = loginPin(body.value("pin", ""), err);
else
result = loginPassword(body.value("username", ""), body.value("password", ""), err);
if (!result)
{
HttpUtil::jsonError(res, 401, err);
return;
}
const std::string token = result->value("token", "");
res.set_header("Set-Cookie",
std::string(kSessionCookie) + "=" + token + "; Path=/; HttpOnly; SameSite=Lax");
res.set_content(result->dump(), "application/json; charset=utf-8");
HttpUtil::addCors(res);
});
svr.Post("/api/auth/logout", [this](const httplib::Request& req, httplib::Response& res) {
const std::string token = extractToken(req);
logout(token);
res.set_header("Set-Cookie", std::string(kSessionCookie) + "=; Path=/; HttpOnly; Max-Age=0");
res.set_content(R"({"ok":true})", "application/json; charset=utf-8");
HttpUtil::addCors(res);
});
svr.Get("/api/auth/me", [this](const httplib::Request& req, httplib::Response& res) {
const std::string token = extractToken(req);
const auto info = sessionInfo(token);
if (!info)
{
HttpUtil::jsonError(res, 401, "not authenticated");
return;
}
nlohmann::json out = {{"user", *info}};
res.set_content(out.dump(), "application/json; charset=utf-8");
HttpUtil::addCors(res);
});
svr.Put("/api/auth/password", [this](const httplib::Request& req, httplib::Response& res) {
nlohmann::json body;
try
{
body = nlohmann::json::parse(req.body);
}
catch (...)
{
HttpUtil::jsonError(res, 400, "invalid json");
return;
}
const std::string token = extractToken(req);
std::string err;
if (!changePassword(token,
body.value("current_password", ""),
body.value("new_password", ""),
err))
{
HttpUtil::jsonError(res, 400, err);
return;
}
res.set_content(R"({"ok":true})", "application/json; charset=utf-8");
HttpUtil::addCors(res);
});
svr.Get("/api/user_groups", [this](const httplib::Request&, httplib::Response& res) {
nlohmann::json out = {{"groups", listGroups()}};
res.set_content(out.dump(), "application/json; charset=utf-8");
HttpUtil::addCors(res);
});
svr.Get("/api/users", [this](const httplib::Request&, httplib::Response& res) {
nlohmann::json out = {{"users", listUsers()}};
res.set_content(out.dump(), "application/json; charset=utf-8");
HttpUtil::addCors(res);
});
svr.Post("/api/users", [this](const httplib::Request& req, httplib::Response& res) {
nlohmann::json body;
try
{
body = nlohmann::json::parse(req.body);
}
catch (...)
{
HttpUtil::jsonError(res, 400, "invalid json");
return;
}
std::string err;
const auto user = createUser(body, err);
if (!user)
{
HttpUtil::jsonError(res, 400, err);
return;
}
res.status = 201;
res.set_content(user->dump(), "application/json; charset=utf-8");
HttpUtil::addCors(res);
});
svr.Put(R"(/api/users/([^/]+))", [this](const httplib::Request& req, httplib::Response& res) {
nlohmann::json body;
try
{
body = nlohmann::json::parse(req.body);
}
catch (...)
{
HttpUtil::jsonError(res, 400, "invalid json");
return;
}
std::string err;
const auto user = updateUser(req.matches[1].str(), body, err);
if (!user)
{
HttpUtil::jsonError(res, 400, err);
return;
}
res.set_content(user->dump(), "application/json; charset=utf-8");
HttpUtil::addCors(res);
});
svr.Delete(R"(/api/users/([^/]+))", [this](const httplib::Request& req, httplib::Response& res) {
std::string err;
if (!deleteUser(req.matches[1].str(), err))
{
HttpUtil::jsonError(res, 400, err);
return;
}
res.set_content(R"({"ok":true})", "application/json; charset=utf-8");
HttpUtil::addCors(res);
});
}
} // namespace lm

79
src/auth/auth_service.hpp Normal file
View File

@@ -0,0 +1,79 @@
#pragma once
#include <httplib.h>
#include <nlohmann/json.hpp>
#include <filesystem>
#include <mutex>
#include <optional>
#include <string>
#include <unordered_map>
namespace lm {
struct AuthSession
{
std::string token;
std::string user_id;
std::string username;
std::string group_id;
std::string group_name;
nlohmann::json permissions;
};
class AuthService
{
public:
explicit AuthService(std::filesystem::path store_path);
httplib::Server::HandlerResponse preRoute(const httplib::Request& req, httplib::Response& res);
const AuthSession* currentSession() const;
std::optional<nlohmann::json> loginPassword(const std::string& username,
const std::string& password,
std::string& err);
std::optional<nlohmann::json> loginPin(const std::string& pin, std::string& err);
bool logout(const std::string& token);
std::optional<nlohmann::json> sessionInfo(const std::string& token) const;
bool changePassword(const std::string& token,
const std::string& current_password,
const std::string& new_password,
std::string& err);
nlohmann::json listGroups() const;
nlohmann::json listUsers() const;
std::optional<nlohmann::json> createUser(const nlohmann::json& payload, std::string& err);
std::optional<nlohmann::json> updateUser(const std::string& id,
const nlohmann::json& payload,
std::string& err);
bool deleteUser(const std::string& id, std::string& err);
void registerRoutes(httplib::Server& svr);
private:
std::filesystem::path store_path_;
mutable std::mutex mu_;
nlohmann::json data_;
std::unordered_map<std::string, AuthSession> sessions_;
thread_local static const AuthSession* tls_session_;
void loadOrSeed();
void saveUnlocked();
std::string extractToken(const httplib::Request& req) const;
std::optional<AuthSession> buildSessionUnlocked(const nlohmann::json& user);
bool permissionAllows(const nlohmann::json& perms, const std::string& resource, bool write) const;
bool authorizeApiRequest(const httplib::Request& req, httplib::Response& res);
static bool isPublicApiPath(const std::string& path, const std::string& method);
static std::optional<std::string> resourceForApiPath(const std::string& path);
static bool requiresWrite(const std::string& method);
static nlohmann::json userPublicView(const nlohmann::json& user, const nlohmann::json& group);
const nlohmann::json* findUserByIdUnlocked(const std::string& id) const;
const nlohmann::json* findUserByUsernameUnlocked(const std::string& username) const;
const nlohmann::json* findGroupByIdUnlocked(const std::string& id) const;
bool verifyPasswordUnlocked(const nlohmann::json& user, const std::string& password) const;
bool verifyPinUnlocked(const nlohmann::json& user, const std::string& pin) const;
bool groupAllowsPinUnlocked(const std::string& group_id) const;
};
} // namespace lm

143
src/util/crypto_util.cpp Normal file
View File

@@ -0,0 +1,143 @@
#include "util/crypto_util.hpp"
#include <array>
#include <cstdint>
#include <cstring>
#include <fstream>
#include <iomanip>
#include <random>
#include <sstream>
#include <vector>
namespace lm {
namespace {
constexpr std::uint32_t rotr(std::uint32_t x, std::uint32_t n)
{
return (x >> n) | (x << (32 - n));
}
void sha256Transform(std::array<std::uint32_t, 8>& state, const std::uint8_t block[64])
{
static const std::uint32_t k[64] = {
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2};
std::uint32_t w[64];
for (int i = 0; i < 16; ++i)
{
w[i] = (static_cast<std::uint32_t>(block[i * 4]) << 24) |
(static_cast<std::uint32_t>(block[i * 4 + 1]) << 16) |
(static_cast<std::uint32_t>(block[i * 4 + 2]) << 8) |
static_cast<std::uint32_t>(block[i * 4 + 3]);
}
for (int i = 16; i < 64; ++i)
{
const std::uint32_t s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >> 3);
const std::uint32_t s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >> 10);
w[i] = w[i - 16] + s0 + w[i - 7] + s1;
}
std::uint32_t a = state[0];
std::uint32_t b = state[1];
std::uint32_t c = state[2];
std::uint32_t d = state[3];
std::uint32_t e = state[4];
std::uint32_t f = state[5];
std::uint32_t g = state[6];
std::uint32_t h = state[7];
for (int i = 0; i < 64; ++i)
{
const std::uint32_t S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
const std::uint32_t ch = (e & f) ^ ((~e) & g);
const std::uint32_t temp1 = h + S1 + ch + k[i] + w[i];
const std::uint32_t S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
const std::uint32_t maj = (a & b) ^ (a & c) ^ (b & c);
const std::uint32_t temp2 = S0 + maj;
h = g;
g = f;
f = e;
e = d + temp1;
d = c;
c = b;
b = a;
a = temp1 + temp2;
}
state[0] += a;
state[1] += b;
state[2] += c;
state[3] += d;
state[4] += e;
state[5] += f;
state[6] += g;
state[7] += h;
}
} // namespace
std::string CryptoUtil::sha256Hex(const std::string& data)
{
std::array<std::uint32_t, 8> state = {0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19};
const std::uint64_t bit_len = static_cast<std::uint64_t>(data.size()) * 8;
std::vector<std::uint8_t> msg(data.begin(), data.end());
msg.push_back(0x80);
while ((msg.size() % 64) != 56)
msg.push_back(0x00);
for (int i = 7; i >= 0; --i)
msg.push_back(static_cast<std::uint8_t>((bit_len >> (i * 8)) & 0xff));
for (std::size_t offset = 0; offset < msg.size(); offset += 64)
sha256Transform(state, msg.data() + offset);
std::ostringstream oss;
for (const auto v : state)
oss << std::hex << std::setw(8) << std::setfill('0') << v;
return oss.str();
}
std::string CryptoUtil::randomToken(std::size_t bytes)
{
std::array<unsigned char, 64> buf{};
const std::size_t n = bytes > buf.size() ? buf.size() : bytes;
std::ifstream urandom("/dev/urandom", std::ios::binary);
if (urandom)
urandom.read(reinterpret_cast<char*>(buf.data()), static_cast<std::streamsize>(n));
else
{
std::random_device rd;
std::mt19937 gen(rd());
std::uniform_int_distribution<int> dist(0, 255);
for (std::size_t i = 0; i < n; ++i)
buf[i] = static_cast<unsigned char>(dist(gen));
}
std::ostringstream oss;
for (std::size_t i = 0; i < n; ++i)
oss << std::hex << std::setw(2) << std::setfill('0') << static_cast<int>(buf[i]);
return oss.str();
}
std::string CryptoUtil::hashPassword(const std::string& salt, const std::string& password)
{
return sha256Hex(salt + ":" + password);
}
std::string CryptoUtil::hashPin(const std::string& salt, const std::string& pin)
{
return sha256Hex(salt + ":pin:" + pin);
}
} // namespace lm

16
src/util/crypto_util.hpp Normal file
View File

@@ -0,0 +1,16 @@
#pragma once
#include <string>
namespace lm {
class CryptoUtil
{
public:
static std::string sha256Hex(const std::string& data);
static std::string randomToken(std::size_t bytes = 32);
static std::string hashPassword(const std::string& salt, const std::string& password);
static std::string hashPin(const std::string& salt, const std::string& pin);
};
} // namespace lm

View File

@@ -26,6 +26,16 @@ def resolve_mission_id(api: requests.Session) -> str:
return ids[0]
def login_admin(api: requests.Session) -> None:
r = api.post(
f"{BASE}/api/auth/login",
json={"username": "Admin", "password": "admin"},
timeout=TIMEOUT,
)
assert r.status_code == 200, r.text
assert r.json().get("user", {}).get("username") == "Admin"
@pytest.fixture(scope="module")
def api():
session = requests.Session()
@@ -41,6 +51,7 @@ def api():
time.sleep(0.2)
else:
pytest.fail(f"Server not ready at {BASE}")
login_admin(session)
return session
@@ -78,6 +89,38 @@ def test_health(api):
assert r.json()["ok"] is True
def test_auth_me(api):
r = api.get(f"{BASE}/api/auth/me", timeout=TIMEOUT)
assert r.status_code == 200
user = r.json().get("user", {})
assert user.get("username") == "Admin"
assert user.get("permissions", {}).get("missions") == "write"
def test_auth_unauthorized_without_session():
session = requests.Session()
r = session.get(f"{BASE}/api/missions", timeout=TIMEOUT)
assert r.status_code == 401
def test_auth_user_read_only_missions():
session = requests.Session()
login = session.post(
f"{BASE}/api/auth/login",
json={"username": "User", "password": "user"},
timeout=TIMEOUT,
)
assert login.status_code == 200
listed = session.get(f"{BASE}/api/missions", timeout=TIMEOUT)
assert listed.status_code == 200
created = session.post(
f"{BASE}/api/triggers",
json={"name": "deny-trigger", "coil_id": 1009, "mission_id": "testmission00001"},
timeout=TIMEOUT,
)
assert created.status_code == 403
def test_missions_available(api, mission_id):
r = api.get(f"{BASE}/api/missions", timeout=TIMEOUT)
assert r.status_code == 200

View File

@@ -123,6 +123,10 @@ const state = {
function setActivePage(page) {
const valid = ["dashboard", "config", "missions", "integrations"];
let p = valid.includes(page) ? page : "config";
if (window.AuthApp && !window.AuthApp.canAccessPage(p)) {
const fallback = valid.find((v) => window.AuthApp.canAccessPage(v));
p = fallback || "dashboard";
}
if (page === "overview") p = "dashboard";
navItemEls.forEach((a) => {
const on = (a.dataset.page || "") === p;
@@ -562,6 +566,7 @@ function setStatus(msg) {
async function api(path, opts = {}) {
const res = await fetch(path, {
credentials: "include",
headers: { "Content-Type": "application/json" },
...opts,
});
@@ -3359,7 +3364,8 @@ function initRobotModelPanelCollapse() {
}
initLayoutManagerEvents();
initNavigation();
if (window.AuthApp?.isReady()) initNavigation();
else window.addEventListener("lm:auth-ready", () => initNavigation(), { once: true });
initSplitPane();
initLidarForm();
initMotorWheelsEvents();
@@ -3402,21 +3408,25 @@ saveLayoutBtn.addEventListener("click", async () => {
});
(async () => {
try {
await api("/api/health");
await loadMotorCatalog();
await loadAll();
selectedText.textContent = "none";
selectedRelText.textContent = "";
setStatus("Sẵn sàng");
} catch (e) {
const msg = String(e.message || e);
if (overviewBackendEl) overviewBackendEl.textContent = `Lỗi: ${msg}`;
if (msg.includes("stack") || msg.includes("Maximum call")) {
setStatus(`Lỗi JavaScript: ${msg}`);
} else {
setStatus(`Không kết nối được backend: ${msg}`);
const boot = async () => {
try {
await api("/api/health");
await loadMotorCatalog();
await loadAll();
selectedText.textContent = "none";
selectedRelText.textContent = "—";
setStatus("Sẵn sàng");
} catch (e) {
const msg = String(e.message || e);
if (overviewBackendEl) overviewBackendEl.textContent = `Lỗi: ${msg}`;
if (msg.includes("stack") || msg.includes("Maximum call")) {
setStatus(`Lỗi JavaScript: ${msg}`);
} else {
setStatus(`Không kết nối được backend: ${msg}`);
}
}
}
};
if (window.AuthApp?.isReady()) await boot();
else window.AuthApp?.whenReady(() => { boot(); });
})();

372
www/auth.js Normal file
View File

@@ -0,0 +1,372 @@
(() => {
const el = (id) => document.getElementById(id);
const loginScreenEl = el("loginScreen");
const shellEl = document.querySelector(".shell");
const loginFormEl = el("loginForm");
const loginPanelPasswordEl = el("loginPanelPassword");
const loginPanelPinEl = el("loginPanelPin");
const loginKeypadEl = el("loginKeypad");
const loginPinHiddenEl = el("loginPin");
const loginErrorEl = el("loginError");
const loginPinErrorEl = el("loginPinError");
const loginTabPasswordEl = el("loginTabPassword");
const loginTabPinEl = el("loginTabPin");
const userMenuBtnEl = el("userMenuBtn");
const userMenuPanelEl = el("userMenuPanel");
const userMenuNameEl = el("userMenuName");
const userMenuGroupEl = el("userMenuGroup");
const changePasswordDialogEl = el("changePasswordDialog");
const changePasswordFormEl = el("changePasswordForm");
const changePasswordErrorEl = el("changePasswordError");
let currentUser = null;
let ready = false;
let loginMode = "password";
let pinDigits = [];
let pinSubmitting = false;
async function apiJson(path, opts = {}) {
const res = await fetch(path, {
credentials: "include",
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
...opts,
});
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = null;
}
if (!res.ok) {
const msg = (data && data.error) || text || res.statusText;
throw new Error(msg);
}
return data;
}
function showError(msg, mode = loginMode) {
const target = mode === "pin" ? loginPinErrorEl : loginErrorEl;
const other = mode === "pin" ? loginErrorEl : loginPinErrorEl;
if (other) {
other.textContent = "";
other.setAttribute("hidden", "");
}
if (!target) return;
target.textContent = msg || "";
if (msg) target.removeAttribute("hidden");
else target.setAttribute("hidden", "");
}
function renderPinCells() {
document.querySelectorAll(".loginPinCell").forEach((cell, index) => {
cell.classList.toggle("filled", index < pinDigits.length);
cell.classList.toggle("active", index === pinDigits.length && pinDigits.length < 4);
});
}
function resetPin() {
pinDigits = [];
pinSubmitting = false;
if (loginPinHiddenEl) loginPinHiddenEl.value = "";
renderPinCells();
showError("", "pin");
}
async function submitPinFromKeypad() {
if (pinSubmitting || pinDigits.length !== 4) return;
pinSubmitting = true;
setLoginLoading(true);
showError("", "pin");
try {
await loginPin(pinDigits.join(""));
} catch (e) {
const msg = String(e.message || "");
if (msg.includes("invalid pin") || msg.includes("401")) {
showError("Mã PIN không hợp lệ. Liên hệ quản trị viên.", "pin");
} else {
showError(msg || "Mã PIN không hợp lệ", "pin");
}
resetPin();
setLoginLoading(false);
}
}
function appendPinDigit(digit) {
if (pinSubmitting || pinDigits.length >= 4) return;
pinDigits.push(digit);
if (loginPinHiddenEl) loginPinHiddenEl.value = pinDigits.join("");
renderPinCells();
if (pinDigits.length === 4) submitPinFromKeypad();
}
function backspacePin() {
if (pinSubmitting) return;
pinDigits.pop();
if (loginPinHiddenEl) loginPinHiddenEl.value = pinDigits.join("");
renderPinCells();
showError("", "pin");
}
function setLoginLoading(loading) {
loginScreenEl?.classList.toggle("is-loading", loading);
document.querySelectorAll(".loginSubmitLabel").forEach((label) => {
label.textContent = loading ? "Đang đăng nhập…" : "Đăng nhập";
});
}
function setLoginMode(mode) {
loginMode = mode;
const pin = mode === "pin";
loginPanelPasswordEl?.toggleAttribute("hidden", pin);
loginPanelPinEl?.toggleAttribute("hidden", !pin);
loginTabPasswordEl?.classList.toggle("active", !pin);
loginTabPinEl?.classList.toggle("active", pin);
loginTabPasswordEl?.setAttribute("aria-selected", pin ? "false" : "true");
loginTabPinEl?.setAttribute("aria-selected", pin ? "true" : "false");
showError("", "password");
showError("", "pin");
if (pin) {
resetPin();
renderPinCells();
}
}
function permissionLevel(resource) {
const perms = currentUser?.permissions || {};
return perms[resource] || "none";
}
function canAccessPage(page) {
const map = {
dashboard: "dashboard",
config: "config",
missions: "missions",
integrations: "integrations",
};
const resource = map[page];
if (!resource) return true;
return permissionLevel(resource) !== "none";
}
function canWrite(resource) {
return permissionLevel(resource) === "write";
}
function applyNavPermissions() {
document.querySelectorAll(".navItem[data-page]").forEach((a) => {
const page = a.dataset.page || "";
const allowed = canAccessPage(page);
a.hidden = !allowed;
a.style.display = allowed ? "" : "none";
});
document.body.classList.toggle("auth-readonly-config", !canWrite("config"));
document.body.classList.toggle("auth-readonly-missions", !canWrite("missions"));
document.body.classList.toggle("auth-readonly-integrations", !canWrite("integrations"));
}
function updateUserMenu() {
if (!currentUser) return;
if (userMenuNameEl) userMenuNameEl.textContent = currentUser.display_name || currentUser.username || "—";
if (userMenuGroupEl) userMenuGroupEl.textContent = currentUser.group_name || "—";
if (userMenuBtnEl) {
const label = currentUser.display_name || currentUser.username || "User";
userMenuBtnEl.textContent = label;
userMenuBtnEl.title = `${label} (${currentUser.group_name || ""})`;
}
}
function unlockApp() {
setLoginLoading(false);
if (loginScreenEl) {
loginScreenEl.setAttribute("hidden", "");
loginScreenEl.style.display = "none";
}
if (shellEl) {
shellEl.classList.remove("auth-locked");
shellEl.style.display = "";
}
applyNavPermissions();
updateUserMenu();
ready = true;
window.dispatchEvent(new CustomEvent("lm:auth-ready", { detail: { user: currentUser } }));
}
function lockApp() {
ready = false;
currentUser = null;
if (shellEl) shellEl.classList.add("auth-locked");
if (loginScreenEl) {
loginScreenEl.removeAttribute("hidden");
loginScreenEl.style.display = "";
}
showError("", "password");
showError("", "pin");
resetPin();
}
async function tryRestoreSession() {
try {
const data = await apiJson("/api/auth/me");
currentUser = data.user;
unlockApp();
return true;
} catch {
lockApp();
return false;
}
}
async function loginPassword(username, password) {
showError("", "password");
const data = await apiJson("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
currentUser = data.user;
unlockApp();
}
async function loginPin(pin) {
showError("", "pin");
const data = await apiJson("/api/auth/login", {
method: "POST",
body: JSON.stringify({ pin }),
});
currentUser = data.user;
unlockApp();
}
async function logout() {
try {
await apiJson("/api/auth/logout", { method: "POST", body: "{}" });
} catch {
/* ignore */
}
currentUser = null;
ready = false;
lockApp();
userMenuPanelEl?.setAttribute("hidden", "");
window.dispatchEvent(new Event("lm:auth-logout"));
}
function bindEvents() {
loginTabPasswordEl?.addEventListener("click", (evt) => {
evt.preventDefault();
setLoginMode("password");
});
loginTabPinEl?.addEventListener("click", (evt) => {
evt.preventDefault();
setLoginMode("pin");
});
loginFormEl?.addEventListener("submit", async (evt) => {
evt.preventDefault();
const username = el("loginUsername")?.value?.trim() || "";
const password = el("loginPasswordInput")?.value || "";
if (!username || !password) {
showError("Nhập tên đăng nhập và mật khẩu", "password");
return;
}
setLoginLoading(true);
showError("", "password");
try {
await loginPassword(username, password);
} catch (e) {
const msg = String(e.message || "");
if (msg.includes("credentials") || msg.includes("401")) {
showError("Sai tên đăng nhập hoặc mật khẩu. Thử Admin / admin", "password");
} else if (msg.includes("fetch") || msg.includes("Failed")) {
showError("Không kết nối được server. Kiểm tra http://localhost:8080", "password");
} else {
showError(msg || "Đăng nhập thất bại", "password");
}
setLoginLoading(false);
}
});
loginKeypadEl?.addEventListener("click", (evt) => {
const btn = evt.target.closest("[data-key]");
if (!btn || pinSubmitting) return;
const key = btn.getAttribute("data-key");
if (key === "back") {
backspacePin();
return;
}
if (/^[0-9]$/.test(key)) appendPinDigit(key);
});
document.addEventListener("keydown", (evt) => {
if (loginMode !== "pin" || pinSubmitting) return;
if (/^[0-9]$/.test(evt.key)) {
evt.preventDefault();
appendPinDigit(evt.key);
} else if (evt.key === "Backspace") {
evt.preventDefault();
backspacePin();
}
});
userMenuBtnEl?.addEventListener("click", (evt) => {
evt.stopPropagation();
const open = userMenuPanelEl?.hasAttribute("hidden");
if (open) userMenuPanelEl?.removeAttribute("hidden");
else userMenuPanelEl?.setAttribute("hidden", "");
});
document.addEventListener("click", () => {
userMenuPanelEl?.setAttribute("hidden", "");
});
el("userMenuSignOutBtn")?.addEventListener("click", (evt) => {
evt.preventDefault();
logout();
});
el("userMenuChangePasswordBtn")?.addEventListener("click", (evt) => {
evt.preventDefault();
userMenuPanelEl?.setAttribute("hidden", "");
changePasswordErrorEl && (changePasswordErrorEl.textContent = "");
changePasswordDialogEl?.showModal();
});
changePasswordFormEl?.addEventListener("submit", async (evt) => {
evt.preventDefault();
const current = el("changePasswordCurrent")?.value || "";
const next = el("changePasswordNew")?.value || "";
const confirm = el("changePasswordConfirm")?.value || "";
if (next !== confirm) {
if (changePasswordErrorEl) changePasswordErrorEl.textContent = "Mật khẩu mới không khớp";
return;
}
try {
await apiJson("/api/auth/password", {
method: "PUT",
body: JSON.stringify({ current_password: current, new_password: next }),
});
changePasswordDialogEl?.close();
changePasswordFormEl.reset();
} catch (e) {
if (changePasswordErrorEl) changePasswordErrorEl.textContent = e.message || "Đổi mật khẩu thất bại";
}
});
}
window.AuthApp = {
isReady: () => ready,
getUser: () => currentUser,
canAccessPage,
canWrite,
logout,
whenReady(fn) {
if (ready) fn(currentUser);
else window.addEventListener("lm:auth-ready", () => fn(currentUser), { once: true });
},
};
bindEvents();
setLoginMode("password");
shellEl?.classList.add("auth-locked");
tryRestoreSession();
})();

View File

@@ -349,6 +349,7 @@
}
function startDashboardPoll() {
if (window.AuthApp && !window.AuthApp.isReady()) return;
stopDashboardPoll();
missions()?.refreshQueue?.();
store.queueUnsub = missions()?.onQueueUpdate?.(() => refreshDynamicWidgets());
@@ -386,5 +387,10 @@
},
};
init();
function boot() {
init();
}
if (window.AuthApp?.isReady()) boot();
else window.addEventListener("lm:auth-ready", boot, { once: true });
window.addEventListener("lm:auth-logout", stopDashboardPoll);
})();

View File

@@ -7,7 +7,88 @@
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<div class="shell">
<div id="loginScreen" class="loginScreen">
<div class="loginFrame">
<header class="loginHeader">
<div class="loginHeaderBrand" id="loginRobotLabel">RobotApp</div>
<div class="loginHeaderRight">
<span class="loginHeaderPrompt">Chọn cách đăng nhập:</span>
<div class="loginTabs" role="tablist">
<button id="loginTabPassword" type="button" class="loginTab active" role="tab" aria-selected="true">
Tên đăng nhập và mật khẩu
</button>
<button id="loginTabPin" type="button" class="loginTab" role="tab" aria-selected="false">
Mã PIN
</button>
</div>
</div>
</header>
<div class="loginCard">
<div id="loginPanelPassword" class="loginPanel">
<div id="loginHelpPassword" class="loginHelp">
<h2 class="loginHelpTitle">Đăng nhập bằng tên và mật khẩu</h2>
<p>Nhập tên đăng nhập và mật khẩu để truy cập robot.</p>
<p>Tài khoản do quản trị viên cấp hoặc xem trong tài liệu hướng dẫn robot.</p>
<p>Nếu chưa có tài khoản, vui lòng liên hệ quản trị viên robot.</p>
</div>
<div class="loginForms">
<form id="loginForm" class="loginForm" action="#" method="post">
<label class="loginField">
<span class="loginFieldLabel">Tên đăng nhập:</span>
<input id="loginUsername" name="username" type="text" autocomplete="username" placeholder="Admin" required />
</label>
<label class="loginField">
<span class="loginFieldLabel">Mật khẩu:</span>
<input id="loginPasswordInput" name="password" type="password" autocomplete="current-password" placeholder="" required />
</label>
<button type="submit" class="loginSubmit" data-mode="password">
<svg class="loginSubmitIcon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path fill="currentColor" d="M12.65 10A5.99 5.99 0 0 0 7 6c-3.31 0-6 2.69-6 6s2.69 6 6 6a5.99 5.99 0 0 0 5.65-4H17v2h3v-2h1v-3h-3V9h-1.35zM7 14a4 4 0 1 1 0-8 4 4 0 0 1 0 8z"/>
</svg>
<span class="loginSubmitLabel">Đăng nhập</span>
</button>
</form>
<p id="loginError" class="loginError" hidden></p>
</div>
</div>
<div id="loginPanelPin" class="loginPanel loginPanel--pin" hidden>
<div class="loginPinLeft">
<div class="loginHelp">
<h2 class="loginHelpTitle">Đăng nhập bằng mã PIN</h2>
<p>Người dùng được kích hoạt PIN có thể đăng nhập tại đây.</p>
<p>Nếu chưa có mã PIN 4 chữ số, vui lòng liên hệ quản trị viên robot.</p>
<p class="loginHelpNote">Không có mã PIN cấu hình sẵn — quản trị viên phải gán PIN trước.</p>
</div>
<div class="loginPinBoxes" id="loginPinBoxes" role="group" aria-label="Mã PIN 4 chữ số">
<div class="loginPinCell" data-idx="0"></div>
<div class="loginPinCell" data-idx="1"></div>
<div class="loginPinCell" data-idx="2"></div>
<div class="loginPinCell" data-idx="3"></div>
</div>
<input id="loginPin" type="hidden" value="" autocomplete="off" />
<p id="loginPinError" class="loginError loginPinError" hidden></p>
</div>
<div class="loginKeypad" id="loginKeypad" aria-label="Bàn phím số">
<button type="button" class="loginKey" data-key="1">1</button>
<button type="button" class="loginKey" data-key="2">2</button>
<button type="button" class="loginKey" data-key="3">3</button>
<button type="button" class="loginKey" data-key="4">4</button>
<button type="button" class="loginKey" data-key="5">5</button>
<button type="button" class="loginKey" data-key="6">6</button>
<button type="button" class="loginKey" data-key="7">7</button>
<button type="button" class="loginKey" data-key="8">8</button>
<button type="button" class="loginKey" data-key="9">9</button>
<button type="button" class="loginKey loginKey--wide" data-key="0">0</button>
<button type="button" class="loginKey loginKey--back" data-key="back" aria-label="Xóa"></button>
</div>
</div>
</div>
</div>
</div>
<div class="shell auth-locked">
<aside class="sidebar">
<div class="brand">
<div class="brandIcon">R</div>
@@ -56,6 +137,17 @@
<div class="pageTitle">Cấu Hình</div>
</div>
<div class="topbarActions">
<div class="userMenuWrap">
<button id="userMenuBtn" type="button" class="btn subtle userMenuBtn" aria-haspopup="true"></button>
<div id="userMenuPanel" class="userMenuPanel" hidden>
<div class="userMenuHeader">
<div id="userMenuName" class="userMenuName"></div>
<div id="userMenuGroup" class="userMenuGroup mutedNote"></div>
</div>
<button id="userMenuChangePasswordBtn" type="button" class="userMenuItem">Đổi mật khẩu</button>
<button id="userMenuSignOutBtn" type="button" class="userMenuItem userMenuItemDanger">Đăng xuất</button>
</div>
</div>
<button id="refreshBtn" type="button" class="btn subtle">Tải lại</button>
<button id="saveLayoutBtn" class="btn primary" type="button">Lưu layout</button>
</div>
@@ -922,6 +1014,35 @@ GET /api/v2.0.0/status</pre>
</form>
</dialog>
<dialog id="changePasswordDialog" class="missionDialog">
<form id="changePasswordForm" method="dialog" class="missionDialogForm">
<div class="missionDialogHeader">
<h3>Đổi mật khẩu</h3>
<button type="button" class="iconBtn missionDialogClose" onclick="document.getElementById('changePasswordDialog').close()" aria-label="Đóng">×</button>
</div>
<div class="missionDialogBody">
<div class="row rowWide">
<label for="changePasswordCurrent">Mật khẩu hiện tại</label>
<input id="changePasswordCurrent" type="password" autocomplete="current-password" required />
</div>
<div class="row rowWide">
<label for="changePasswordNew">Mật khẩu mới</label>
<input id="changePasswordNew" type="password" autocomplete="new-password" required minlength="4" />
</div>
<div class="row rowWide">
<label for="changePasswordConfirm">Xác nhận mật khẩu mới</label>
<input id="changePasswordConfirm" type="password" autocomplete="new-password" required minlength="4" />
</div>
<p id="changePasswordError" class="loginError"></p>
</div>
<div class="missionDialogFooter">
<button type="button" class="btn subtle" onclick="document.getElementById('changePasswordDialog').close()">Hủy</button>
<button type="submit" class="btn primary">Lưu</button>
</div>
</form>
</dialog>
<script src="/auth.js"></script>
<script src="/missions.js"></script>
<script src="/dashboard.js"></script>
<script src="/integrations.js"></script>

View File

@@ -40,7 +40,10 @@
}
async function apiJson(url, opts = {}) {
const res = await fetch(url, opts);
if (window.AuthApp && !window.AuthApp.isReady()) {
throw new Error("not authenticated");
}
const res = await fetch(url, { credentials: "include", ...opts });
const text = await res.text();
let data = null;
try {
@@ -440,5 +443,9 @@
refreshAll,
};
init();
function boot() {
init();
}
if (window.AuthApp?.isReady()) boot();
else window.addEventListener("lm:auth-ready", boot, { once: true });
})();

View File

@@ -188,7 +188,7 @@
async function loadStoreFromBackend() {
try {
const res = await fetch("/api/missions");
const res = await fetch("/api/missions", { credentials: "include" });
if (!res.ok) return false;
const data = await res.json();
if (Array.isArray(data.missions)) store.missions = data.missions;
@@ -209,6 +209,7 @@
async function syncStoreToBackend() {
try {
await fetch("/api/missions", {
credentials: "include",
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ missions: store.missions, groups: store.groups }),
@@ -396,7 +397,11 @@
}
async function missionApi(path, opts = {}) {
if (window.AuthApp && !window.AuthApp.isReady()) {
throw new Error("not authenticated");
}
const res = await fetch(path, {
credentials: "include",
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
...opts,
});
@@ -479,6 +484,7 @@
}
async function refreshQueue() {
if (window.AuthApp && !window.AuthApp.isReady()) return;
try {
const data = await missionApi("/api/mission_queue");
store.queue = Array.isArray(data.queue) ? data.queue : [];
@@ -486,6 +492,7 @@
renderQueuePanel();
notifyQueueUpdate();
} catch (e) {
if (String(e.message || "").includes("not authenticated")) return;
if (missionQueueRunnerEl) missionQueueRunnerEl.textContent = `Không tải được queue: ${e.message}`;
}
}
@@ -706,6 +713,7 @@
}
function startQueuePoll() {
if (window.AuthApp && !window.AuthApp.isReady()) return;
stopQueuePoll();
refreshQueue();
store.queuePollTimer = setInterval(refreshQueue, 1500);
@@ -1368,5 +1376,10 @@
},
};
init();
function boot() {
init();
}
if (window.AuthApp?.isReady()) boot();
else window.addEventListener("lm:auth-ready", boot, { once: true });
window.addEventListener("lm:auth-logout", stopQueuePoll);
})();

View File

@@ -1034,3 +1034,408 @@ canvas {
.integrationCode { font-size: 13px; word-break: break-all; }
.integrationTestRow .integrationTestActions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.integrationTestRow select { min-width: 220px; }
/* --- Auth / Sign in (MiR §2.1) --- */
.shell.auth-locked { display: none !important; }
.loginScreen[hidden] { display: none !important; }
.loginForm[hidden] { display: none !important; }
.loginHelp[hidden] { display: none !important; }
.loginError[hidden] { display: none !important; }
.loginPanel {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 40px;
padding: 36px 40px 40px;
}
.loginPanel--pin {
align-items: start;
}
.loginPanel[hidden] { display: none !important; }
.loginScreen {
--mir-blue: #3d6cb3;
--mir-blue-dark: #2f5a9a;
--mir-green: #5cb85c;
--mir-green-hover: #4cae4c;
--mir-tab-inactive: #c8c8c8;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 32px 24px;
background: var(--mir-blue);
font-family: "Segoe UI", ui-sans-serif, system-ui, -apple-system, Roboto, Helvetica, Arial, sans-serif;
}
.loginFrame {
width: min(920px, 100%);
}
.loginHeader {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
flex-wrap: wrap;
padding: 0 8px 0 4px;
margin-bottom: -1px;
}
.loginHeaderBrand {
color: #fff;
font-size: clamp(2rem, 5vw, 2.75rem);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1;
padding-bottom: 12px;
}
.loginHeaderRight {
display: flex;
align-items: flex-end;
gap: 14px;
flex-wrap: wrap;
margin-left: auto;
padding-bottom: 8px;
}
.loginHeaderPrompt {
color: #fff;
font-size: 15px;
font-weight: 600;
white-space: nowrap;
padding-bottom: 10px;
}
.loginTabs {
display: flex;
align-items: flex-end;
gap: 4px;
}
.loginTab {
border: none;
cursor: pointer;
font-size: 14px;
font-weight: 600;
padding: 12px 18px;
border-radius: 6px 6px 0 0;
background: var(--mir-tab-inactive);
color: #333;
transition: background 0.15s ease;
white-space: nowrap;
}
.loginTab:hover:not(.active) {
background: #d8d8d8;
}
.loginTab.active {
background: #fff;
color: #111;
padding-bottom: 13px;
}
.loginCard {
background: #fff;
border-radius: 0 8px 8px 8px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
min-height: 320px;
}
.loginHelpNote {
font-size: 13px;
color: #888;
font-style: italic;
}
.loginPinLeft {
display: flex;
flex-direction: column;
gap: 0;
}
.loginPinBoxes {
display: flex;
gap: 14px;
margin-top: 28px;
}
.loginPinCell {
width: 54px;
height: 54px;
border: 2px solid #c8c8c8;
border-radius: 10px;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
line-height: 1;
color: #111;
transition: border-color 0.15s ease;
}
.loginPinCell.filled::after {
content: "•";
font-weight: 700;
}
.loginPinCell.active {
border-color: var(--mir-blue);
box-shadow: 0 0 0 2px rgba(61, 108, 179, 0.2);
}
.loginPinError {
margin-top: 16px;
}
.loginKeypad {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
width: min(100%, 300px);
margin: 0 auto;
align-self: center;
}
.loginKey {
border: none;
border-radius: 12px;
background: var(--mir-green);
color: #fff;
font-size: 26px;
font-weight: 600;
line-height: 1;
padding: 20px 0;
cursor: pointer;
user-select: none;
transition: background 0.12s ease, transform 0.08s ease;
min-height: 64px;
}
.loginKey:hover {
background: var(--mir-green-hover);
}
.loginKey:active {
transform: scale(0.97);
}
.loginKey--wide {
grid-column: span 2;
}
.loginKey--back {
background: #3a3a3a;
font-size: 22px;
font-weight: 700;
line-height: 1;
}
.loginKey--back:hover {
background: #2a2a2a;
}
.loginScreen.is-loading .loginKey {
opacity: 0.55;
pointer-events: none;
}
.loginHelp {
color: #666;
font-size: 14px;
line-height: 1.55;
}
.loginHelpTitle {
margin: 0 0 16px;
color: #222;
font-size: 1.35rem;
font-weight: 700;
line-height: 1.3;
}
.loginHelp p {
margin: 0 0 14px;
}
.loginHelp p:last-child {
margin-bottom: 0;
}
.loginForms {
display: flex;
flex-direction: column;
justify-content: center;
min-height: 220px;
}
.loginForm {
display: grid;
gap: 18px;
}
.loginField {
display: grid;
gap: 8px;
}
.loginFieldLabel {
font-size: 14px;
font-weight: 700;
color: #222;
}
.loginField input {
width: 100%;
padding: 10px 12px;
font-size: 15px;
border: 1px solid #c5c5c5;
border-radius: 4px;
background: #fff;
color: #111;
box-sizing: border-box;
}
.loginField input:focus {
outline: 2px solid rgba(61, 108, 179, 0.35);
border-color: var(--mir-blue);
}
.loginField input:-webkit-autofill {
-webkit-box-shadow: 0 0 0 1000px #fff8d6 inset;
box-shadow: 0 0 0 1000px #fff8d6 inset;
}
.loginSubmit {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
align-self: flex-start;
margin-top: 4px;
padding: 10px 22px;
border: none;
border-radius: 6px;
background: var(--mir-green);
color: #fff;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease;
}
.loginSubmit:hover {
background: var(--mir-green-hover);
}
.loginSubmit:active {
transform: translateY(1px);
}
.loginSubmitIcon {
flex-shrink: 0;
opacity: 0.95;
}
.loginScreen.is-loading .loginSubmit {
opacity: 0.75;
pointer-events: none;
}
.loginError {
color: #b42318;
font-size: 13px;
margin: 12px 0 0;
padding: 10px 12px;
border-radius: 6px;
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.25);
}
@media (max-width: 768px) {
.loginHeader {
flex-direction: column;
align-items: stretch;
}
.loginHeaderRight {
flex-direction: column;
align-items: stretch;
margin-left: 0;
}
.loginHeaderPrompt {
padding-bottom: 0;
}
.loginTabs {
width: 100%;
}
.loginTab {
flex: 1;
text-align: center;
border-radius: 6px 6px 0 0;
white-space: normal;
font-size: 13px;
padding: 10px 8px;
}
.loginPanel {
grid-template-columns: 1fr;
gap: 24px;
padding: 24px 20px 28px;
}
.loginKeypad {
width: 100%;
max-width: 320px;
}
.loginCard {
border-radius: 8px;
}
}
.userMenuWrap { position: relative; }
.userMenuBtn { max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.userMenuPanel {
position: absolute;
top: calc(100% + 6px);
right: 0;
min-width: 220px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: var(--shadow2);
padding: 8px;
z-index: 50;
}
.userMenuHeader { padding: 8px 10px 10px; border-bottom: 1px solid var(--border); margin-bottom: 4px; }
.userMenuName { font-weight: 600; }
.userMenuGroup { font-size: 12px; }
.userMenuItem {
display: block;
width: 100%;
text-align: left;
border: none;
background: transparent;
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
}
.userMenuItem:hover { background: var(--panel2); }
.userMenuItemDanger { color: var(--danger); }
body.auth-readonly-config #saveLayoutBtn,
body.auth-readonly-config .btn.primary[data-write-config] { display: none !important; }
body.auth-readonly-missions .missionToolbar .btn.primary,
body.auth-readonly-missions #missionCreateBtn { pointer-events: none; opacity: 0.45; }
body.auth-readonly-integrations .integrationToolbar .btn.primary { pointer-events: none; opacity: 0.45; }