Compare commits
2 Commits
4b372100eb
...
9aee5f4100
| Author | SHA1 | Date | |
|---|---|---|---|
| 9aee5f4100 | |||
| 6fa15b69e7 |
@@ -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
|
||||
|
||||
15
README.md
15
README.md
@@ -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
76
data/auth.json
Normal 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
BIN
docs/Reference guide.pdf
Normal file
Binary file not shown.
446
docs/Reference_guide.md
Normal file
446
docs/Reference_guide.md
Normal 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 1–9, 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 **1001–2000**)
|
||||
- 9.4. Action commands (coil **1–6**)
|
||||
|
||||
---
|
||||
|
||||
## 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.1–2)
|
||||
- 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 1001–2000)
|
||||
Rising edge coil → enqueue mission đã gắn `mission_id`.
|
||||
|
||||
### Action commands (coil 1–6)
|
||||
| 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 1001–2000 | **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*.
|
||||
BIN
docs/mir250_user_guide_11_en.pdf
Normal file
BIN
docs/mir250_user_guide_11_en.pdf
Normal file
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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
776
src/auth/auth_service.cpp
Normal 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
79
src/auth/auth_service.hpp
Normal 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
143
src/util/crypto_util.cpp
Normal 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
16
src/util/crypto_util.hpp
Normal 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
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
42
www/app.js
42
www/app.js
@@ -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
372
www/auth.js
Normal 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();
|
||||
})();
|
||||
@@ -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);
|
||||
})();
|
||||
|
||||
123
www/index.html
123
www/index.html
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
})();
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
|
||||
405
www/style.css
405
www/style.css
@@ -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; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user