Compare commits
29 Commits
70691dcd4a
...
v1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35ff93c72f | ||
|
|
01564a5953 | ||
|
|
bc18448be7 | ||
|
|
6f90bd2891 | ||
|
|
eeea8dfad8 | ||
|
|
4389a20e90 | ||
|
|
8f72d692e2 | ||
|
|
e2de0ae1fa | ||
|
|
db3966a1d7 | ||
|
|
46ba26a86d | ||
|
|
adcc2b9522 | ||
|
|
876e44272b | ||
|
|
d51141ceef | ||
|
|
7e84587788 | ||
|
|
9f703c1bfa | ||
|
|
c4355eb371 | ||
|
|
a5470544a1 | ||
|
|
54f1ebb62d | ||
|
|
0264425383 | ||
|
|
ff097b31d1 | ||
|
|
0cb35a30cb | ||
|
|
69e4f594de | ||
|
|
d803e2d7f6 | ||
|
|
bd5403b2d3 | ||
|
|
5b546f2698 | ||
|
|
893a5e4750 | ||
|
|
d30b320595 | ||
|
|
e153d54917 | ||
|
|
888348579d |
13
.env.example
@@ -5,11 +5,18 @@ ADMIN_PASSWORD=changeme123
|
|||||||
# JWT signing secret generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
# JWT signing secret generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||||
SECRET_KEY=replace-with-a-random-64-char-hex-string
|
SECRET_KEY=replace-with-a-random-64-char-hex-string
|
||||||
|
|
||||||
# Set to true to skip real AVConnect calls (for testing)
|
|
||||||
# MOCK_AVCONNECT=true
|
|
||||||
|
|
||||||
# Port configuration
|
# Port configuration
|
||||||
APP_PORT=8000
|
APP_PORT=8000
|
||||||
|
|
||||||
|
# CORS allowed origins (comma-separated)
|
||||||
|
CORS_ORIGINS=http://localhost:8000
|
||||||
|
|
||||||
|
# Trusted proxy IPs for correct client IP extraction (comma-separated)
|
||||||
|
TRUSTED_PROXY_IPS=127.0.0.1
|
||||||
|
|
||||||
# Database path (default: ./data/gates.db relative to project root)
|
# Database path (default: ./data/gates.db relative to project root)
|
||||||
# DATABASE_URL=sqlite:///./data/gates.db
|
# DATABASE_URL=sqlite:///./data/gates.db
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
#LOG_FILE=logs/app.log
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
# Use Python 3.11 slim image
|
# Use Python 3.11 slim image
|
||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Ensure Python output is sent straight to stdout/stderr (no buffering),
|
||||||
|
# which is required for logs to appear in `docker logs`.
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1
|
||||||
|
|
||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -17,8 +22,8 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
COPY src/ ./src/
|
COPY src/ ./src/
|
||||||
COPY data/ ./data/
|
COPY data/ ./data/
|
||||||
|
|
||||||
# Create data directory if it doesn't exist
|
# Create data and log directories if they don't exist
|
||||||
RUN mkdir -p data
|
RUN mkdir -p data logs
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE $APP_PORT
|
EXPOSE $APP_PORT
|
||||||
|
|||||||
18
LICENSE
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 ettore.dreucci
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||||
|
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||||
|
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
||||||
|
following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||||
|
portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
||||||
|
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
||||||
|
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||||
|
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||||
|
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
300
README.md
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|

|
||||||
|
|
||||||
|
# Lagomare Gates
|
||||||
|
|
||||||
|
A web-based gate access management and control system. Authorized users can remotely open physical gates via a mobile-friendly PWA. An admin dashboard provides full management of gates, access codes, and users.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Keypass authentication** — users authenticate with an access code; each keypass can have a per-gate allowlist, an optional expiration date, and an optional time/day-of-week schedule
|
||||||
|
- **Keypass schedules** — restrict a keypass to specific days of the week and/or a time window (e.g. Monday–Friday 08:00–18:00, server local time)
|
||||||
|
- **Remote gate control** — integrates with [AVConnect](https://www.avconnect.it) and [Shelly Cloud](https://shelly.cloud) to trigger gate macros/relays
|
||||||
|
- **Gate icons** — each gate can be assigned any UTF-8 character or emoji as its icon, displayed on the user app button and keypass selector
|
||||||
|
- **Role-based admin panel** — two roles (`admin`, `manager`) with different permission levels
|
||||||
|
- **Two-factor authentication (TOTP)** — admins can enable app-based 2FA (Google Authenticator, Authy, etc.) on their account
|
||||||
|
- **Access audit log** — every open attempt is logged with timestamp, keypass, gate, IP, and result; filterable and paginated
|
||||||
|
- **Keypass QR codes** — generate a scannable QR code for each keypass; scanning opens the PWA and logs in automatically
|
||||||
|
- **Keypass code options** — choose character set (alphanumeric, alpha, numeric, or a 4-word passphrase) and length when auto-generating codes
|
||||||
|
- **Telegram notifications** — optional push notifications to a Telegram group whenever a gate is opened
|
||||||
|
- **Progressive Web App** — installable on mobile devices with offline caching
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|---|---|
|
||||||
|
| Backend | FastAPI + Uvicorn |
|
||||||
|
| ORM | SQLAlchemy |
|
||||||
|
| Database | SQLite |
|
||||||
|
| Auth | JWT (HS256) + bcrypt |
|
||||||
|
| 2FA | TOTP (RFC 6238) via pyotp |
|
||||||
|
| Credential storage | Fernet symmetric encryption |
|
||||||
|
| Gate integration | AVConnect HTTP API / Shelly Cloud API |
|
||||||
|
| Notifications | Telegram Bot API |
|
||||||
|
| QR generation | qrcode + Pillow |
|
||||||
|
| Frontend | Vanilla JS PWA |
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── main.py # App entry point, startup, static file serving
|
||||||
|
├── core/
|
||||||
|
│ ├── auth.py # JWT creation/verification, password hashing
|
||||||
|
│ ├── config.py # Settings loaded from environment variables
|
||||||
|
│ ├── database.py # SQLAlchemy models and DB initialization
|
||||||
|
│ ├── dependencies.py # FastAPI dependency injection (auth guards)
|
||||||
|
│ └── schemas.py # Pydantic request/response schemas
|
||||||
|
├── routers/
|
||||||
|
│ ├── auth.py # POST /api/auth/admin, POST /api/auth/keypass
|
||||||
|
│ ├── gates.py # User-facing gate list and open endpoints
|
||||||
|
│ ├── keypasses.py # Admin keypass CRUD + QR code generation
|
||||||
|
│ ├── admins.py # Admin user management
|
||||||
|
│ ├── credentials.py # AVConnect credential management
|
||||||
|
│ ├── stats.py # Access log / statistics (paginated, filtered)
|
||||||
|
│ └── telegram.py # Telegram notification configuration
|
||||||
|
├── services/
|
||||||
|
│ ├── avconnect.py # AVConnect API client
|
||||||
|
│ ├── gates.py # Gate open orchestration
|
||||||
|
| |── shelly.py # Shelly Cloud API client
|
||||||
|
│ └── telegram.py # Telegram Bot API client
|
||||||
|
└── static/ # Frontend PWA (index.html, admin.html, JS, CSS)
|
||||||
|
data/
|
||||||
|
└── gates.db # SQLite database (auto-created on first run)
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | `/api/auth/admin` | Admin login — returns JWT |
|
||||||
|
| POST | `/api/auth/keypass` | Keypass login — returns JWT |
|
||||||
|
|
||||||
|
### User (keypass token required)
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/gates` | List gates accessible to the authenticated keypass |
|
||||||
|
| POST | `/api/gates/{gate_id}/open` | Open a gate |
|
||||||
|
|
||||||
|
### Admin — Gates (manager+)
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/admin/gates` | List all gates |
|
||||||
|
| POST | `/api/admin/gates` | Create a gate *(admin only)* |
|
||||||
|
| PUT | `/api/admin/gates/{gate_id}` | Update a gate *(admin only)* |
|
||||||
|
| DELETE | `/api/admin/gates/{gate_id}` | Delete a gate *(admin only)* |
|
||||||
|
| POST | `/api/admin/gates/{gate_id}/open` | Manually open a gate |
|
||||||
|
|
||||||
|
### Admin — Keypasses (manager+)
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/admin/keypasses` | List all keypasses |
|
||||||
|
| POST | `/api/admin/keypasses` | Create a keypass |
|
||||||
|
| PATCH | `/api/admin/keypasses/{kp_id}` | Update a keypass (description, expiry, gates, schedule) |
|
||||||
|
| DELETE | `/api/admin/keypasses/{kp_id}` | Revoke a keypass |
|
||||||
|
| GET | `/api/admin/keypasses/{kp_id}/qr` | Download QR code PNG for a keypass |
|
||||||
|
|
||||||
|
### Admin — Users (admin only)
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/admin/admins` | List admin users |
|
||||||
|
| POST | `/api/admin/admins` | Create an admin user |
|
||||||
|
| DELETE | `/api/admin/admins/{username}` | Delete an admin user |
|
||||||
|
| PATCH | `/api/admin/admins/{username}/password` | Change password |
|
||||||
|
| POST | `/api/admin/admins/{username}/totp/setup` | Generate a new TOTP secret and return provisioning URI + QR |
|
||||||
|
| POST | `/api/admin/admins/{username}/totp/enable` | Verify a TOTP code and activate 2FA |
|
||||||
|
| DELETE | `/api/admin/admins/{username}/totp` | Disable 2FA and discard the secret |
|
||||||
|
|
||||||
|
### Admin — AVConnect Credentials (admin only)
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/admin/credentials/avconnect` | View stored AVConnect credentials |
|
||||||
|
| PUT | `/api/admin/credentials/avconnect` | Create or update AVConnect credentials |
|
||||||
|
| GET | `/api/admin/credentials/shelly` | View stored Shelly Cloud credentials |
|
||||||
|
| PUT | `/api/admin/credentials/shelly` | Create or update Shelly Cloud credentials |
|
||||||
|
| GET | `/api/admin/credentials/mock` | Get mock mode status |
|
||||||
|
| PUT | `/api/admin/credentials/mock` | Enable or disable mock mode |
|
||||||
|
|
||||||
|
### Admin — Statistics (manager+)
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/admin/stats` | Paginated, filtered access log |
|
||||||
|
|
||||||
|
Query parameters: `gate_id`, `keypass_code` (partial match), `success` (bool), `date_from`, `date_to`, `page` (default 1), `page_size` (default 50, max 200).
|
||||||
|
|
||||||
|
### Admin — Telegram Notifications (admin only)
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/admin/telegram` | Get current Telegram configuration |
|
||||||
|
| PUT | `/api/admin/telegram` | Save bot token and chat ID |
|
||||||
|
| POST | `/api/admin/telegram/test` | Send a test message |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All settings are read from environment variables (centralised in `src/core/config.py`).
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `SECRET_KEY` | *(required)* | JWT signing key and Fernet encryption key. The application will refuse to start if this is not set. Use a long random string (`openssl rand -hex 32`). |
|
||||||
|
|
||||||
|
### Admin seed account
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `ADMIN_USERNAME` | `admin` | Username for the initial admin account created on first run. |
|
||||||
|
| `ADMIN_PASSWORD` | *(none)* | Password for the initial admin account. If unset, no seed account is created. Minimum 12 characters. |
|
||||||
|
|
||||||
|
### Server
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `APP_PORT` | `8000` | HTTP port the server listens on. |
|
||||||
|
|
||||||
|
### Database
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `DATABASE_URL` | `sqlite:///data/gates.db` | SQLAlchemy database URL. |
|
||||||
|
|
||||||
|
### Map (home location)
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `HOME_LAT` | *(none)* | WGS-84 latitude of the home/property marker shown on the frontend map. |
|
||||||
|
| `HOME_LON` | *(none)* | WGS-84 longitude of the home/property marker. |
|
||||||
|
| `HOME_NAME` | `Home` | Display name for the home marker popup. |
|
||||||
|
|
||||||
|
If `HOME_LAT` and `HOME_LON` are both set, the map is always visible. If only gates have coordinates (set per-gate in the admin panel), the map is shown only when at least one gate has a location.
|
||||||
|
|
||||||
|
### Network / reverse proxy
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `CORS_ORIGINS` | *(empty — no cross-origin requests)* | Comma-separated list of allowed CORS origins, e.g. `https://gates.example.com`. |
|
||||||
|
| `TRUSTED_PROXY_IPS` | `127.0.0.1` | Comma-separated list of reverse-proxy IPs whose `X-Forwarded-For` header is trusted for client IP resolution. |
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `LOG_LEVEL` | `INFO` | Logging verbosity. One of `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`. |
|
||||||
|
| `LOG_FILE` | `/var/log/lagomaregates.log` | Path to the rotating log file (10 MB, 5 backups). Set to an empty string to disable file logging. |
|
||||||
|
|
||||||
|
## Running with Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy and edit the compose file to set your SECRET_KEY
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The default `docker-compose.yml` starts the service on port `8000`. Set a strong `SECRET_KEY` and, optionally, `ADMIN_USERNAME` / `ADMIN_PASSWORD` before deploying.
|
||||||
|
|
||||||
|
The `./data` directory is mounted into the container so the SQLite database persists across restarts.
|
||||||
|
|
||||||
|
## Running Locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
export SECRET_KEY="$(openssl rand -hex 32)"
|
||||||
|
export ADMIN_USERNAME="admin"
|
||||||
|
export ADMIN_PASSWORD="changeme-at-least-12"
|
||||||
|
|
||||||
|
uvicorn src.main:app --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
The application is then available at:
|
||||||
|
|
||||||
|
- **User interface** — `http://localhost:8000/`
|
||||||
|
- **Admin dashboard** — `http://localhost:8000/admin`
|
||||||
|
|
||||||
|
## AVConnect Integration
|
||||||
|
|
||||||
|
Gates are controlled through one of two supported API providers.
|
||||||
|
|
||||||
|
### AVConnect
|
||||||
|
|
||||||
|
Each gate is mapped to an AVConnect *macro ID*. When a gate open request is received, the service:
|
||||||
|
|
||||||
|
1. Authenticates with AVConnect using the stored credentials (session is cached in the database)
|
||||||
|
2. Executes the configured macro for the gate
|
||||||
|
|
||||||
|
Credentials (password) are stored encrypted in the database using Fernet symmetric encryption derived from `SECRET_KEY`.
|
||||||
|
|
||||||
|
### Shelly Cloud
|
||||||
|
|
||||||
|
Each gate is mapped to a Shelly *device ID*. The service calls the Shelly Cloud API with the stored auth key to activate the device's relay.
|
||||||
|
|
||||||
|
The Shelly server URI and auth key are stored encrypted. Configure them under **Admin → Credentials → Shelly Cloud**.
|
||||||
|
|
||||||
|
**Mock mode** — when enabled via the admin dashboard, gate open requests always succeed without contacting any external API. Useful for testing.
|
||||||
|
|
||||||
|
## Keypass Schedules
|
||||||
|
|
||||||
|
In addition to an expiry date and a per-gate allowlist, each keypass can be restricted to a configurable time window:
|
||||||
|
|
||||||
|
- **Days of the week** — select any combination of Mon–Sun. If no day is checked the restriction applies on any day.
|
||||||
|
- **Time window** — a `From` / `To` time in 24-hour format (server local time). Both values must be provided together.
|
||||||
|
|
||||||
|
When a keypass with a schedule is used outside its allowed window the API returns **HTTP 403** (`Keypass is not valid today` / `Keypass is not valid at this time`) and the gate will not open. The restriction is enforced in `require_keypass` in `src/core/dependencies.py`.
|
||||||
|
|
||||||
|
The schedule is stored as a JSON object in the `schedule` column of the `keypasses` table:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "days": [0, 1, 2, 3, 4], "time_start": "08:00", "time_end": "18:00" }
|
||||||
|
```
|
||||||
|
|
||||||
|
`days` uses Python's `weekday()` convention: 0 = Monday, 6 = Sunday. Any absent key is treated as unrestricted (e.g. omitting `days` means any day is allowed).
|
||||||
|
|
||||||
|
## Keypass QR Codes
|
||||||
|
|
||||||
|
Each active keypass has a **QR** button in the admin panel. Clicking it generates a PNG QR code that encodes the URL:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://<your-domain>/?k=<KEYPASS_CODE>
|
||||||
|
```
|
||||||
|
|
||||||
|
Scanning the code with a phone opens the web app and logs in automatically. If the keypass is expired or revoked, the user is shown an error on the login screen.
|
||||||
|
|
||||||
|
## Telegram Notifications
|
||||||
|
|
||||||
|
Configure a Telegram bot to receive a message in a group or chat every time a gate is opened:
|
||||||
|
|
||||||
|
1. Create a bot via [@BotFather](https://t.me/BotFather) and copy the token
|
||||||
|
2. Add the bot to your group and obtain the chat ID (e.g. using [@userinfobot](https://t.me/userinfobot))
|
||||||
|
3. Open **Admin → Notifications**, enter the token and chat ID, and click **Save**
|
||||||
|
4. Use **Send test message** to verify the setup
|
||||||
|
|
||||||
|
Notifications are sent in a background thread and never block the gate open response. Failures are logged as warnings and do not affect gate operation.
|
||||||
|
|
||||||
|
## Two-Factor Authentication (TOTP)
|
||||||
|
|
||||||
|
Each admin account can independently enable TOTP-based two-factor authentication:
|
||||||
|
|
||||||
|
1. Open **Admin → Admin Users** and click **Enable 2FA** on your own row
|
||||||
|
2. Scan the QR code with an authenticator app (Google Authenticator, Authy, 1Password, etc.)
|
||||||
|
3. Enter the 6-digit code to confirm — 2FA is only activated after a successful verification
|
||||||
|
4. On subsequent logins, after entering your password you will be prompted for the current TOTP code
|
||||||
|
|
||||||
|
To disable, click **Disable 2FA** on your row and confirm.
|
||||||
|
|
||||||
|
> Only the account owner can enable or disable their own 2FA. TOTP secrets are stored Fernet-encrypted in the database.
|
||||||
|
|
||||||
|
## Roles
|
||||||
|
|
||||||
|
| Role | Permissions |
|
||||||
|
|---|---|
|
||||||
|
| `admin` | Full access — all endpoints including gate/user/credential/notification management |
|
||||||
|
| `manager` | Gate open, keypass management, statistics — cannot manage admin users, AVConnect credentials, gate configuration, or Telegram settings |
|
||||||
@@ -7,10 +7,14 @@ services:
|
|||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
|
- ./logs:/app/logs
|
||||||
environment:
|
environment:
|
||||||
- ADMIN_USERNAME=admin
|
- ADMIN_USERNAME=admin
|
||||||
- ADMIN_PASSWORD=changeme
|
- ADMIN_PASSWORD=changeme
|
||||||
- SECRET_KEY=supersecretkey
|
- SECRET_KEY=supersecretkey
|
||||||
- MOCK_AVCONNECT=false
|
|
||||||
- APP_PORT=8000
|
- APP_PORT=8000
|
||||||
|
- CORS_ORIGINS=http://localhost:8000
|
||||||
|
- TRUSTED_PROXY_IPS=127.0.0.1
|
||||||
|
- LOG_LEVEL=INFO
|
||||||
|
- LOG_FILE=/app/logs/app.log
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -7,3 +7,5 @@ cryptography>=42.0.0
|
|||||||
requests>=2.31.0
|
requests>=2.31.0
|
||||||
fake-useragent>=1.5.0
|
fake-useragent>=1.5.0
|
||||||
python-multipart>=0.0.9
|
python-multipart>=0.0.9
|
||||||
|
qrcode[pil]>=7.4
|
||||||
|
pyotp>=2.9
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
|
||||||
import secrets
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
|
|
||||||
|
from core.config import SECRET_KEY
|
||||||
|
|
||||||
# ── Password hashing ──────────────────────────────────────────────────────────
|
# ── Password hashing ──────────────────────────────────────────────────────────
|
||||||
ALGORITHM = "HS256"
|
ALGORITHM = "HS256"
|
||||||
|
|
||||||
# Loaded once at import time; changing SECRET_KEY invalidates all existing tokens.
|
|
||||||
SECRET_KEY: str = os.environ.get("SECRET_KEY") or secrets.token_hex(32)
|
|
||||||
|
|
||||||
|
|
||||||
def hash_password(plain: str) -> str:
|
def hash_password(plain: str) -> str:
|
||||||
return bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode()
|
return bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode()
|
||||||
@@ -40,7 +37,7 @@ def decode_token(token: str) -> Optional[dict]:
|
|||||||
|
|
||||||
# ── Symmetric encryption for AVConnect passwords ─────────────────────────────
|
# ── Symmetric encryption for AVConnect passwords ─────────────────────────────
|
||||||
# Derive a stable 32-byte Fernet key from SECRET_KEY so only one env var is needed.
|
# Derive a stable 32-byte Fernet key from SECRET_KEY so only one env var is needed.
|
||||||
_raw = os.environ.get("SECRET_KEY") or SECRET_KEY
|
_raw = SECRET_KEY
|
||||||
_fernet_key = base64.urlsafe_b64encode(hashlib.sha256(_raw.encode()).digest())
|
_fernet_key = base64.urlsafe_b64encode(hashlib.sha256(_raw.encode()).digest())
|
||||||
_fernet = Fernet(_fernet_key)
|
_fernet = Fernet(_fernet_key)
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,63 @@
|
|||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
MOCK_AVCONNECT: bool = os.environ.get("MOCK_AVCONNECT", "").lower() in ("1", "true", "yes")
|
|
||||||
|
def utcnow() -> datetime:
|
||||||
|
"""Return the current UTC time as a timezone-naive datetime.
|
||||||
|
|
||||||
|
SQLite (and SQLAlchemy's default column handling) stores datetimes without
|
||||||
|
timezone info. Using this helper keeps all DB timestamps and comparisons
|
||||||
|
consistent and avoids TypeError on offset-naive vs offset-aware comparisons.
|
||||||
|
"""
|
||||||
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Paths ─────────────────────────────────────────────────────────────────────
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__)) # src/core/
|
||||||
|
_SRC_DIR = os.path.dirname(_HERE) # src/
|
||||||
|
_PROJECT_ROOT = os.path.dirname(_SRC_DIR) # project root
|
||||||
|
DATA_DIR: str = os.path.join(_PROJECT_ROOT, "data")
|
||||||
|
|
||||||
|
# ── Logging ───────────────────────────────────────────────────────────────────
|
||||||
|
# LOG_LEVEL: one of DEBUG, INFO, WARNING, ERROR, CRITICAL (default: INFO)
|
||||||
|
LOG_LEVEL: int = getattr(logging, os.environ.get("LOG_LEVEL", "INFO").upper(), logging.INFO)
|
||||||
|
# LOG_FILE: set to empty string to disable file logging
|
||||||
|
LOG_FILE: str = os.environ.get("LOG_FILE", "")
|
||||||
|
|
||||||
|
# ── Security ──────────────────────────────────────────────────────────────────
|
||||||
|
SECRET_KEY: str = os.environ.get("SECRET_KEY") or ""
|
||||||
|
if not SECRET_KEY:
|
||||||
|
raise RuntimeError("SECRET_KEY environment variable must be set")
|
||||||
|
|
||||||
|
# ── Database ──────────────────────────────────────────────────────────────────
|
||||||
|
DATABASE_URL: str = os.environ.get(
|
||||||
|
"DATABASE_URL",
|
||||||
|
f"sqlite:///{os.path.join(DATA_DIR, 'gates.db')}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── CORS ──────────────────────────────────────────────────────────────────────
|
||||||
|
# Comma-separated list of allowed origins, e.g. "https://example.com,https://app.example.com"
|
||||||
|
# Default to empty list (no cross-origin requests allowed) when not set.
|
||||||
|
_cors_env = os.environ.get("CORS_ORIGINS", "")
|
||||||
|
CORS_ORIGINS: list[str] = [o.strip() for o in _cors_env.split(",") if o.strip()]
|
||||||
|
|
||||||
|
# ── Proxy ─────────────────────────────────────────────────────────────────────
|
||||||
|
# Comma-separated list of trusted reverse-proxy IPs for X-Forwarded-For propagation.
|
||||||
|
# e.g. "127.0.0.1,10.0.0.1"
|
||||||
|
_proxy_env = os.environ.get("TRUSTED_PROXY_IPS", "127.0.0.1")
|
||||||
|
TRUSTED_PROXY_IPS: list[str] = [ip.strip() for ip in _proxy_env.split(",") if ip.strip()]
|
||||||
|
|
||||||
|
# ── Admin seed ────────────────────────────────────────────────────────────────
|
||||||
|
ADMIN_USERNAME: str = os.environ.get("ADMIN_USERNAME", "admin")
|
||||||
|
ADMIN_PASSWORD: Optional[str] = os.environ.get("ADMIN_PASSWORD") or None
|
||||||
|
|
||||||
|
# ── Server ────────────────────────────────────────────────────────────────────
|
||||||
|
APP_PORT: int = int(os.environ.get("APP_PORT", 8000))
|
||||||
|
|
||||||
|
# ── Map / home location ───────────────────────────────────────────────────────
|
||||||
|
# Optional WGS-84 coordinates for the "home" marker shown on the frontend map.
|
||||||
|
HOME_LAT: Optional[float] = float(os.environ["HOME_LAT"]) if os.environ.get("HOME_LAT") else None
|
||||||
|
HOME_LON: Optional[float] = float(os.environ["HOME_LON"]) if os.environ.get("HOME_LON") else None
|
||||||
|
HOME_NAME: str = os.environ.get("HOME_NAME", "Home")
|
||||||
|
|||||||
@@ -1,18 +1,11 @@
|
|||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import Boolean, Column, DateTime, Integer, String, Text, create_engine
|
from sqlalchemy import Boolean, Double, String, Text, create_engine, text
|
||||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker
|
||||||
|
|
||||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
from core.config import DATA_DIR, DATABASE_URL
|
||||||
_SRC_DIR = os.path.dirname(_HERE)
|
|
||||||
_PROJECT_ROOT = os.path.dirname(_SRC_DIR)
|
|
||||||
_DATA_DIR = os.path.join(_PROJECT_ROOT, "data")
|
|
||||||
|
|
||||||
DATABASE_URL = os.environ.get(
|
|
||||||
"DATABASE_URL",
|
|
||||||
f"sqlite:///{os.path.join(_DATA_DIR, 'gates.db')}",
|
|
||||||
)
|
|
||||||
|
|
||||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
@@ -25,57 +18,83 @@ class Base(DeclarativeBase):
|
|||||||
class GateDB(Base):
|
class GateDB(Base):
|
||||||
__tablename__ = "gates"
|
__tablename__ = "gates"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
name = Column(String, nullable=False)
|
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
gate_type = Column(String, nullable=False) # 'car' | 'pedestrian'
|
gate_icon: Mapped[str] = mapped_column(String, nullable=False, default="🚪") # any UTF-8 character/emoji
|
||||||
avconnect_macro_id = Column(String, nullable=False) # AVConnect macro ID
|
api_provider: Mapped[str] = mapped_column(String, nullable=False, default="avconnect") # 'avconnect' | 'shelly'
|
||||||
status = Column(String, default="enabled") # 'enabled' | 'disabled'
|
avconnect_macro_id: Mapped[Optional[str]] = mapped_column(String, nullable=True) # AVConnect macro ID
|
||||||
|
shelly_device_id: Mapped[Optional[str]] = mapped_column(String, nullable=True) # Shelly Cloud device ID
|
||||||
|
status: Mapped[str] = mapped_column(String, default="enabled") # 'enabled' | 'disabled'
|
||||||
|
group_name: Mapped[Optional[str]] = mapped_column(String, nullable=True) # display group label
|
||||||
|
lat: Mapped[Optional[float]] = mapped_column(Double, nullable=True) # WGS-84 latitude
|
||||||
|
lon: Mapped[Optional[float]] = mapped_column(Double, nullable=True) # WGS-84 longitude
|
||||||
|
|
||||||
|
|
||||||
class ApiCredential(Base):
|
class ApiCredential(Base):
|
||||||
__tablename__ = "api_credentials"
|
__tablename__ = "api_credentials"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
username = Column(String, nullable=False)
|
username: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
password_enc = Column(String, nullable=False) # Fernet-encrypted
|
password_enc: Mapped[str] = mapped_column(String, nullable=False) # Fernet-encrypted
|
||||||
session_id = Column(String, nullable=True)
|
session_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||||
|
mock_avconnect: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
|
||||||
|
|
||||||
|
class ShellyCredential(Base):
|
||||||
|
__tablename__ = "shelly_credentials"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
server_uri: Mapped[str] = mapped_column(String, nullable=False) # e.g. https://shelly-3.eu.shelly.cloud
|
||||||
|
auth_key_enc: Mapped[str] = mapped_column(String, nullable=False) # Fernet-encrypted auth key
|
||||||
|
|
||||||
|
|
||||||
class Keypass(Base):
|
class Keypass(Base):
|
||||||
__tablename__ = "keypasses"
|
__tablename__ = "keypasses"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
code = Column(String, unique=True, nullable=False)
|
code: Mapped[str] = mapped_column(String, unique=True, nullable=False)
|
||||||
description = Column(Text, nullable=False)
|
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
created_at = Column(DateTime, nullable=False)
|
created_at: Mapped[datetime] = mapped_column(nullable=False)
|
||||||
expires_at = Column(DateTime, nullable=True) # NULL = never expires
|
expires_at: Mapped[Optional[datetime]] = mapped_column(nullable=True) # NULL = never expires
|
||||||
revoked = Column(Boolean, default=False)
|
revoked: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
revoked_at = Column(DateTime, nullable=True)
|
revoked_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)
|
||||||
allowed_gates = Column(Text, nullable=True) # JSON list of gate IDs; NULL = all gates
|
allowed_gates: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # JSON list of gate IDs; NULL = all gates
|
||||||
|
schedule: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # JSON schedule rule; NULL = always allowed
|
||||||
|
|
||||||
|
|
||||||
class GateAccessLog(Base):
|
class GateAccessLog(Base):
|
||||||
__tablename__ = "gate_access_logs"
|
__tablename__ = "gate_access_logs"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
timestamp = Column(DateTime, nullable=False)
|
timestamp: Mapped[datetime] = mapped_column(nullable=False)
|
||||||
keypass_id = Column(Integer, nullable=False)
|
keypass_id: Mapped[int] = mapped_column(nullable=False)
|
||||||
keypass_code = Column(String, nullable=False)
|
keypass_code: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
gate_id = Column(Integer, nullable=False)
|
gate_id: Mapped[int] = mapped_column(nullable=False)
|
||||||
gate_name = Column(String, nullable=False)
|
gate_name: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
ip_address = Column(String, nullable=True)
|
ip_address: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||||
user_agent = Column(Text, nullable=True)
|
user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
success = Column(Boolean, nullable=False)
|
success: Mapped[bool] = mapped_column(Boolean, nullable=False)
|
||||||
error = Column(Text, nullable=True)
|
error: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class AdminUser(Base):
|
class AdminUser(Base):
|
||||||
__tablename__ = "admin_users"
|
__tablename__ = "admin_users"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
username = Column(String, unique=True, nullable=False)
|
username: Mapped[str] = mapped_column(String, unique=True, nullable=False)
|
||||||
password_hash = Column(String, nullable=False)
|
password_hash: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
role = Column(String, nullable=False, default="admin") # 'admin' | 'manager'
|
role: Mapped[str] = mapped_column(String, nullable=False, default="admin") # 'admin' | 'manager'
|
||||||
|
totp_secret_enc: Mapped[Optional[str]] = mapped_column(String, nullable=True) # Fernet-encrypted TOTP secret
|
||||||
|
totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramConfig(Base):
|
||||||
|
__tablename__ = "telegram_config"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
bot_token_enc: Mapped[str] = mapped_column(String, nullable=False) # Fernet-encrypted
|
||||||
|
chat_id: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
@@ -87,5 +106,5 @@ def get_db():
|
|||||||
|
|
||||||
|
|
||||||
def init_db() -> None:
|
def init_db() -> None:
|
||||||
os.makedirs(_DATA_DIR, exist_ok=True)
|
os.makedirs(DATA_DIR, exist_ok=True)
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -6,6 +7,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from core.auth import decode_token
|
from core.auth import decode_token
|
||||||
|
from core.config import utcnow
|
||||||
from core.database import Keypass, get_db
|
from core.database import Keypass, get_db
|
||||||
|
|
||||||
_security = HTTPBearer()
|
_security = HTTPBearer()
|
||||||
@@ -45,6 +47,21 @@ def require_keypass(
|
|||||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass not found")
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass not found")
|
||||||
if kp.revoked:
|
if kp.revoked:
|
||||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass has been revoked")
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass has been revoked")
|
||||||
if kp.expires_at is not None and kp.expires_at < datetime.utcnow():
|
if kp.expires_at is not None and kp.expires_at < utcnow():
|
||||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass has expired")
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass has expired")
|
||||||
|
if kp.schedule:
|
||||||
|
try:
|
||||||
|
sched = json.loads(kp.schedule)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
sched = {}
|
||||||
|
now_local = datetime.now()
|
||||||
|
allowed_days = sched.get("days")
|
||||||
|
if allowed_days is not None and now_local.weekday() not in allowed_days:
|
||||||
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "Keypass not currently allowed")
|
||||||
|
time_start = sched.get("time_start")
|
||||||
|
time_end = sched.get("time_end")
|
||||||
|
if time_start and time_end:
|
||||||
|
now_hhmm = now_local.strftime("%H:%M")
|
||||||
|
if now_hhmm < time_start or now_hhmm > time_end:
|
||||||
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "Keypass not currently allowed")
|
||||||
return kp
|
return kp
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from core.database import Keypass
|
|||||||
class AdminLoginRequest(BaseModel):
|
class AdminLoginRequest(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
password: str
|
password: str
|
||||||
|
otp_code: Optional[str] = None # 6-digit TOTP code; required when account has 2FA enabled
|
||||||
|
|
||||||
|
|
||||||
class KeypassLoginRequest(BaseModel):
|
class KeypassLoginRequest(BaseModel):
|
||||||
@@ -23,19 +24,37 @@ class TokenResponse(BaseModel):
|
|||||||
token_type: str = "bearer"
|
token_type: str = "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
class AdminLoginResponse(BaseModel):
|
||||||
|
token: Optional[str] = None
|
||||||
|
token_type: str = "bearer"
|
||||||
|
otp_required: bool = False
|
||||||
|
|
||||||
|
|
||||||
# ── Keypasses ─────────────────────────────────────────────────────────────────
|
# ── Keypasses ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ScheduleRule(BaseModel):
|
||||||
|
"""Optional time/day-of-week restriction for a keypass."""
|
||||||
|
days: Optional[list[int]] = None # 0=Mon..6=Sun; None/absent = any day
|
||||||
|
time_start: Optional[str] = None # "HH:MM" 24-hour server local time
|
||||||
|
time_end: Optional[str] = None # "HH:MM" 24-hour server local time
|
||||||
|
|
||||||
|
|
||||||
class KeypassCreate(BaseModel):
|
class KeypassCreate(BaseModel):
|
||||||
description: str
|
description: str
|
||||||
expires_at: Optional[datetime] = None # None = never expires
|
expires_at: Optional[datetime] = None # None = never expires
|
||||||
gate_ids: list[int] = [] # empty = all gates
|
gate_ids: list[int] = [] # empty = all gates
|
||||||
code: Optional[str] = None # None = auto-generate
|
code: Optional[str] = None # None = auto-generate
|
||||||
|
# Auto-generation options (ignored when `code` is supplied manually)
|
||||||
|
length: int = 12 # 6–32
|
||||||
|
charset: str = "alphanumeric" # "alphanumeric" | "alpha" | "numeric" | "passphrase"
|
||||||
|
schedule: Optional[ScheduleRule] = None # None = always allowed
|
||||||
|
|
||||||
|
|
||||||
class KeypassPatch(BaseModel):
|
class KeypassPatch(BaseModel):
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
expires_at: Optional[datetime] = None # None = never expires
|
expires_at: Optional[datetime] = None # None = never expires
|
||||||
gate_ids: Optional[list[int]] = None # None = keep unchanged; [] = all gates
|
gate_ids: Optional[list[int]] = None # None = keep unchanged; [] = all gates
|
||||||
|
schedule: Optional[ScheduleRule] = None # absent = keep unchanged; null = clear
|
||||||
|
|
||||||
|
|
||||||
class KeypassResponse(BaseModel):
|
class KeypassResponse(BaseModel):
|
||||||
@@ -48,9 +67,16 @@ class KeypassResponse(BaseModel):
|
|||||||
revoked: bool
|
revoked: bool
|
||||||
revoked_at: Optional[datetime] = None
|
revoked_at: Optional[datetime] = None
|
||||||
allowed_gate_ids: list[int] # empty = all gates
|
allowed_gate_ids: list[int] # empty = all gates
|
||||||
|
schedule: Optional[ScheduleRule] = None
|
||||||
|
|
||||||
|
|
||||||
def keypass_to_response(kp: Keypass) -> KeypassResponse:
|
def keypass_to_response(kp: Keypass) -> KeypassResponse:
|
||||||
|
sched: Optional[ScheduleRule] = None
|
||||||
|
if kp.schedule:
|
||||||
|
try:
|
||||||
|
sched = ScheduleRule(**json.loads(kp.schedule))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return KeypassResponse(
|
return KeypassResponse(
|
||||||
id=kp.id,
|
id=kp.id,
|
||||||
code=kp.code,
|
code=kp.code,
|
||||||
@@ -60,28 +86,51 @@ def keypass_to_response(kp: Keypass) -> KeypassResponse:
|
|||||||
revoked=kp.revoked,
|
revoked=kp.revoked,
|
||||||
revoked_at=kp.revoked_at,
|
revoked_at=kp.revoked_at,
|
||||||
allowed_gate_ids=json.loads(kp.allowed_gates) if kp.allowed_gates else [],
|
allowed_gate_ids=json.loads(kp.allowed_gates) if kp.allowed_gates else [],
|
||||||
|
schedule=sched,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Gates ─────────────────────────────────────────────────────────────────────
|
# ── Gates ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class GateResponse(BaseModel):
|
class GateResponse(BaseModel):
|
||||||
|
"""Full gate response — admin use only."""
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
gate_type: str
|
gate_icon: str
|
||||||
avconnect_macro_id: str
|
api_provider: str
|
||||||
|
avconnect_macro_id: Optional[str] = None
|
||||||
|
shelly_device_id: Optional[str] = None
|
||||||
status: str
|
status: str
|
||||||
|
group_name: Optional[str] = None
|
||||||
|
lat: Optional[float] = None
|
||||||
|
lon: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
class GatePublicResponse(BaseModel):
|
||||||
|
"""Gate response for keypass users — no internal fields."""
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
gate_icon: str
|
||||||
|
group_name: Optional[str] = None
|
||||||
|
lat: Optional[float] = None
|
||||||
|
lon: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
class GateCreate(BaseModel):
|
class GateCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
gate_type: str # 'car' | 'pedestrian'
|
gate_icon: str = "🚪" # any UTF-8 character/emoji
|
||||||
avconnect_macro_id: str
|
api_provider: str = "avconnect" # 'avconnect' | 'shelly'
|
||||||
|
avconnect_macro_id: Optional[str] = None
|
||||||
|
shelly_device_id: Optional[str] = None
|
||||||
status: str = "enabled"
|
status: str = "enabled"
|
||||||
|
group_name: Optional[str] = None
|
||||||
|
lat: Optional[float] = None
|
||||||
|
lon: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
# ── AVConnect Credentials ─────────────────────────────────────────────────────
|
# ── API Credentials ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class CredentialRead(BaseModel):
|
class CredentialRead(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
@@ -93,12 +142,28 @@ class CredentialUpsert(BaseModel):
|
|||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class ShellyCredentialRead(BaseModel):
|
||||||
|
id: int
|
||||||
|
server_uri: str
|
||||||
|
|
||||||
|
|
||||||
|
class ShellyCredentialUpsert(BaseModel):
|
||||||
|
server_uri: str
|
||||||
|
auth_key: str
|
||||||
|
|
||||||
|
|
||||||
# ── Admin users ───────────────────────────────────────────────────────────────
|
# ── Admin users ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class AdminUserResponse(BaseModel):
|
class AdminUserResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
username: str
|
username: str
|
||||||
role: str # 'admin' | 'manager'
|
role: str # 'admin' | 'manager'
|
||||||
|
totp_enabled: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class TotpSetupResponse(BaseModel):
|
||||||
|
provisioning_uri: str # otpauth:// URI for QR scanning
|
||||||
|
qr_image_b64: str # base64-encoded PNG of the QR code
|
||||||
|
|
||||||
|
|
||||||
class AdminUserCreate(BaseModel):
|
class AdminUserCreate(BaseModel):
|
||||||
@@ -125,3 +190,10 @@ class AccessLogResponse(BaseModel):
|
|||||||
user_agent: Optional[str]
|
user_agent: Optional[str]
|
||||||
success: bool
|
success: bool
|
||||||
error: Optional[str]
|
error: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
class StatsPage(BaseModel):
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
items: list[AccessLogResponse]
|
||||||
|
|||||||
137
src/main.py
@@ -1,15 +1,56 @@
|
|||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import logging.handlers
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request, Response
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
||||||
|
|
||||||
# Ensure src/ root is importable for models/services/routers
|
# Ensure src/ root is importable for models/services/routers
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from core.config import ADMIN_PASSWORD, ADMIN_USERNAME, APP_PORT, CORS_ORIGINS, HOME_LAT, HOME_LON, HOME_NAME, LOG_FILE, LOG_LEVEL, TRUSTED_PROXY_IPS
|
||||||
|
|
||||||
|
# ── Logging ───────────────────────────────────────────────────────────────────
|
||||||
|
_log_fmt = logging.Formatter(
|
||||||
|
fmt="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
|
||||||
|
datefmt="%Y-%m-%dT%H:%M:%S",
|
||||||
|
)
|
||||||
|
logging.basicConfig(
|
||||||
|
level=LOG_LEVEL,
|
||||||
|
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
|
||||||
|
datefmt="%Y-%m-%dT%H:%M:%S",
|
||||||
|
)
|
||||||
|
if LOG_FILE:
|
||||||
|
try:
|
||||||
|
_file_handler = logging.handlers.RotatingFileHandler(
|
||||||
|
LOG_FILE,
|
||||||
|
maxBytes=10 * 1024 * 1024, # 10 MB
|
||||||
|
backupCount=5,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
_file_handler.setFormatter(_log_fmt)
|
||||||
|
logging.getLogger().addHandler(_file_handler)
|
||||||
|
# Uvicorn sets propagate=False on its loggers by default; override so
|
||||||
|
# records bubble up to the root logger and reach our file handler.
|
||||||
|
for _uvicorn_logger_name in ("uvicorn", "uvicorn.access", "uvicorn.error"):
|
||||||
|
logging.getLogger(_uvicorn_logger_name).propagate = True
|
||||||
|
except OSError as _e:
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
"Cannot open log file %r — file logging disabled: %s", LOG_FILE, _e
|
||||||
|
)
|
||||||
|
# Quieten noisy third-party loggers
|
||||||
|
logging.getLogger("uvicorn.access").setLevel(max(LOG_LEVEL, logging.INFO))
|
||||||
|
logging.getLogger("sqlalchemy.engine").setLevel(max(LOG_LEVEL, logging.WARNING))
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from core.auth import hash_password
|
from core.auth import hash_password
|
||||||
from core.database import AdminUser, SessionLocal, init_db
|
from core.database import AdminUser, SessionLocal, init_db
|
||||||
from routers.auth import router as auth_router
|
from routers.auth import router as auth_router
|
||||||
@@ -18,20 +59,59 @@ from routers.gates import router as gates_router
|
|||||||
from routers.credentials import router as credentials_router
|
from routers.credentials import router as credentials_router
|
||||||
from routers.admins import router as admins_router
|
from routers.admins import router as admins_router
|
||||||
from routers.stats import router as stats_router
|
from routers.stats import router as stats_router
|
||||||
|
from routers.telegram import router as telegram_router
|
||||||
|
from routers.logs import router as logs_router
|
||||||
|
|
||||||
|
# ── Cache-bust hash (SHA-1 of all static file contents) ──────────────────────
|
||||||
|
def _static_hash(static_dir: str) -> str:
|
||||||
|
h = hashlib.sha1()
|
||||||
|
for root, _, files in sorted(os.walk(static_dir)):
|
||||||
|
for name in sorted(files):
|
||||||
|
with open(os.path.join(root, name), "rb") as f:
|
||||||
|
h.update(f.read())
|
||||||
|
return h.hexdigest()[:8]
|
||||||
|
|
||||||
# ── App ───────────────────────────────────────────────────────────────────────
|
# ── App ───────────────────────────────────────────────────────────────────────
|
||||||
app = FastAPI(title="Lagomare Gates", docs_url=None, redoc_url=None)
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
init_db()
|
||||||
|
_seed_admin()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Lagomare Gates", docs_url=None, redoc_url=None, lifespan=lifespan)
|
||||||
|
|
||||||
_STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
_STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
||||||
app.mount("/static", StaticFiles(directory=_STATIC_DIR), name="static")
|
app.mount("/static", StaticFiles(directory=_STATIC_DIR), name="static")
|
||||||
|
|
||||||
|
# Trust X-Forwarded-For only from configured proxy IPs so request.client.host
|
||||||
|
# is already the real client address everywhere else in the code.
|
||||||
|
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=TRUSTED_PROXY_IPS)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=CORS_ORIGINS,
|
||||||
allow_methods=["*"],
|
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
|
||||||
allow_headers=["*"],
|
allow_headers=["Authorization", "Content-Type"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Inject security headers on every response
|
||||||
|
@app.middleware("http")
|
||||||
|
async def _security_headers(request: Request, call_next) -> Response:
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
|
response.headers["X-Frame-Options"] = "DENY"
|
||||||
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||||
|
response.headers["Content-Security-Policy"] = (
|
||||||
|
"default-src 'self';"
|
||||||
|
" script-src 'self';"
|
||||||
|
" style-src 'self' 'unsafe-inline';"
|
||||||
|
" font-src 'self';"
|
||||||
|
" img-src 'self' data: blob: https://*.tile.openstreetmap.org;"
|
||||||
|
" connect-src 'self' https://*.tile.openstreetmap.org"
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
# ── Routers (Controllers) ─────────────────────────────────────────────────────
|
# ── Routers (Controllers) ─────────────────────────────────────────────────────
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(keypasses_router)
|
app.include_router(keypasses_router)
|
||||||
@@ -39,12 +119,24 @@ app.include_router(gates_router)
|
|||||||
app.include_router(credentials_router)
|
app.include_router(credentials_router)
|
||||||
app.include_router(admins_router)
|
app.include_router(admins_router)
|
||||||
app.include_router(stats_router)
|
app.include_router(stats_router)
|
||||||
|
app.include_router(telegram_router)
|
||||||
|
app.include_router(logs_router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/site-config", include_in_schema=False)
|
||||||
|
async def _site_config():
|
||||||
|
"""Return public site configuration used by the frontend (e.g. home map coordinates)."""
|
||||||
|
return {
|
||||||
|
"home": {"name": HOME_NAME, "lat": HOME_LAT, "lon": HOME_LON}
|
||||||
|
if HOME_LAT is not None and HOME_LON is not None
|
||||||
|
else None
|
||||||
|
}
|
||||||
|
|
||||||
# ── Static / frontend ─────────────────────────────────────────────────────────
|
# ── Static / frontend ─────────────────────────────────────────────────────────
|
||||||
@app.get("/favicon.ico", include_in_schema=False)
|
@app.get("/favicon.ico", include_in_schema=False)
|
||||||
async def _serve_favicon() -> FileResponse:
|
async def _serve_favicon() -> FileResponse:
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
os.path.join(_STATIC_DIR, "logo.svg"), media_type="image/svg+xml"
|
os.path.join(_STATIC_DIR, "images", "logo.svg"), media_type="image/svg+xml"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -59,10 +151,10 @@ async def _serve_admin() -> FileResponse:
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/sw.js", include_in_schema=False)
|
@app.get("/sw.js", include_in_schema=False)
|
||||||
async def _serve_sw() -> FileResponse:
|
async def _serve_sw() -> Response:
|
||||||
return FileResponse(
|
sw_path = os.path.join(_STATIC_DIR, "sw.js")
|
||||||
os.path.join(_STATIC_DIR, "sw.js"), media_type="application/javascript"
|
content = open(sw_path, encoding="utf-8").read().replace("__STATIC_HASH__", _static_hash(os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")))
|
||||||
)
|
return Response(content=content, media_type="application/javascript")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/manifest.json", include_in_schema=False)
|
@app.get("/manifest.json", include_in_schema=False)
|
||||||
@@ -72,28 +164,23 @@ async def _serve_manifest() -> FileResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Startup ───────────────────────────────────────────────────────────────────
|
|
||||||
@app.on_event("startup")
|
|
||||||
async def _startup() -> None:
|
|
||||||
init_db()
|
|
||||||
_seed_admin()
|
|
||||||
|
|
||||||
|
|
||||||
def _seed_admin() -> None:
|
def _seed_admin() -> None:
|
||||||
"""Create the initial admin user from env vars if it doesn't exist yet."""
|
if not ADMIN_PASSWORD:
|
||||||
username = os.environ.get("ADMIN_USERNAME", "admin")
|
|
||||||
password = os.environ.get("ADMIN_PASSWORD")
|
|
||||||
if not password:
|
|
||||||
return
|
return
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
if not db.query(AdminUser).filter_by(username=username).first():
|
if not db.query(AdminUser).filter_by(username=ADMIN_USERNAME).first():
|
||||||
db.add(AdminUser(username=username, password_hash=hash_password(password)))
|
db.add(AdminUser(username=ADMIN_USERNAME, password_hash=hash_password(ADMIN_PASSWORD)))
|
||||||
db.commit()
|
db.commit()
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
port = int(os.environ.get("APP_PORT", 8000))
|
uvicorn.run(
|
||||||
uvicorn.run("main:app", host="0.0.0.0", port=port, reload=True)
|
"main:app",
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=APP_PORT,
|
||||||
|
reload=False,
|
||||||
|
log_level=logging.getLevelName(LOG_LEVEL).lower(),
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
from .credential import Credential
|
|
||||||
from .gate import Gate
|
|
||||||
from .status import Status
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"Credential",
|
|
||||||
"Gate",
|
|
||||||
"Status"
|
|
||||||
]
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
class Credential:
|
|
||||||
def __init__(self, username: str, password: str):
|
|
||||||
self.username = username
|
|
||||||
self.password = password
|
|
||||||
self.sessionid = None
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
from .status import Status
|
|
||||||
from .credential import Credential
|
|
||||||
|
|
||||||
class Gate:
|
|
||||||
def __init__(self, id: str, name: str, status: Status = Status.ENABLED):
|
|
||||||
self.id = id
|
|
||||||
self.name = name
|
|
||||||
self.status = status if isinstance(status, Status) else Status(status)
|
|
||||||
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
from enum import Enum
|
|
||||||
|
|
||||||
class Status(Enum):
|
|
||||||
ENABLED = 1
|
|
||||||
DISABLED = 0
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# routers package
|
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
|
import base64
|
||||||
|
import io
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
import pyotp
|
||||||
|
import qrcode
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from core.auth import hash_password
|
from core.auth import decrypt_secret, encrypt_secret, hash_password
|
||||||
from core.database import AdminUser, get_db
|
from core.database import AdminUser, get_db
|
||||||
from core.dependencies import require_admin
|
from core.dependencies import require_admin
|
||||||
from core.schemas import AdminUserCreate, AdminUserResponse, AdminPasswordChange
|
from core.schemas import AdminPasswordChange, AdminUserCreate, AdminUserResponse, TotpSetupResponse
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin/admins", tags=["admin-admins"])
|
router = APIRouter(prefix="/api/admin/admins", tags=["admin-admins"])
|
||||||
|
|
||||||
@@ -15,7 +20,7 @@ router = APIRouter(prefix="/api/admin/admins", tags=["admin-admins"])
|
|||||||
async def list_admins(
|
async def list_admins(
|
||||||
db: Session = Depends(get_db), _: dict = Depends(require_admin)
|
db: Session = Depends(get_db), _: dict = Depends(require_admin)
|
||||||
):
|
):
|
||||||
return [AdminUserResponse(id=u.id, username=u.username, role=u.role) for u in db.query(AdminUser).order_by(AdminUser.id).all()]
|
return [AdminUserResponse(id=u.id, username=u.username, role=u.role, totp_enabled=u.totp_enabled) for u in db.query(AdminUser).order_by(AdminUser.id).all()]
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=AdminUserResponse, status_code=201)
|
@router.post("", response_model=AdminUserResponse, status_code=201)
|
||||||
@@ -29,6 +34,8 @@ async def create_admin(
|
|||||||
raise HTTPException(422, "Username cannot be empty")
|
raise HTTPException(422, "Username cannot be empty")
|
||||||
if req.role not in ("admin", "manager"):
|
if req.role not in ("admin", "manager"):
|
||||||
raise HTTPException(422, "role must be 'admin' or 'manager'")
|
raise HTTPException(422, "role must be 'admin' or 'manager'")
|
||||||
|
if len(req.password) < 12:
|
||||||
|
raise HTTPException(422, "Password must be at least 12 characters")
|
||||||
if db.query(AdminUser).filter_by(username=username).first():
|
if db.query(AdminUser).filter_by(username=username).first():
|
||||||
raise HTTPException(409, "Username already exists")
|
raise HTTPException(409, "Username already exists")
|
||||||
user = AdminUser(username=username, password_hash=hash_password(req.password), role=req.role)
|
user = AdminUser(username=username, password_hash=hash_password(req.password), role=req.role)
|
||||||
@@ -64,8 +71,77 @@ async def change_password(
|
|||||||
):
|
):
|
||||||
if not req.new_password:
|
if not req.new_password:
|
||||||
raise HTTPException(422, "Password cannot be empty")
|
raise HTTPException(422, "Password cannot be empty")
|
||||||
|
if len(req.new_password) < 12:
|
||||||
|
raise HTTPException(422, "Password must be at least 12 characters")
|
||||||
user: Optional[AdminUser] = db.query(AdminUser).filter_by(username=username).first()
|
user: Optional[AdminUser] = db.query(AdminUser).filter_by(username=username).first()
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(404, "Admin not found")
|
raise HTTPException(404, "Admin not found")
|
||||||
user.password_hash = hash_password(req.new_password)
|
user.password_hash = hash_password(req.new_password)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ── TOTP (2FA) ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/{username}/totp/setup", response_model=TotpSetupResponse)
|
||||||
|
async def totp_setup(
|
||||||
|
username: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
caller: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""Generate a new TOTP secret and return the provisioning URI for QR scanning.
|
||||||
|
Does NOT enable 2FA yet — call /enable with a valid code to activate."""
|
||||||
|
if caller["sub"] != username:
|
||||||
|
raise HTTPException(403, "You can only configure 2FA for your own account")
|
||||||
|
user: Optional[AdminUser] = db.query(AdminUser).filter_by(username=username).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(404, "Admin not found")
|
||||||
|
secret = pyotp.random_base32()
|
||||||
|
user.totp_secret_enc = encrypt_secret(secret)
|
||||||
|
user.totp_enabled = False # pending confirmation
|
||||||
|
db.commit()
|
||||||
|
uri = pyotp.TOTP(secret).provisioning_uri(name=username, issuer_name="Lagomare Gates")
|
||||||
|
buf = io.BytesIO()
|
||||||
|
qrcode.make(uri).save(buf)
|
||||||
|
qr_b64 = base64.b64encode(buf.getvalue()).decode()
|
||||||
|
return TotpSetupResponse(provisioning_uri=uri, qr_image_b64=qr_b64)
|
||||||
|
|
||||||
|
|
||||||
|
class TotpConfirm(BaseModel):
|
||||||
|
otp_code: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{username}/totp/enable", status_code=204)
|
||||||
|
async def totp_enable(
|
||||||
|
username: str,
|
||||||
|
req: TotpConfirm,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
caller: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""Verify the OTP code and activate 2FA for the account."""
|
||||||
|
if caller["sub"] != username:
|
||||||
|
raise HTTPException(403, "You can only configure 2FA for your own account")
|
||||||
|
user: Optional[AdminUser] = db.query(AdminUser).filter_by(username=username).first()
|
||||||
|
if not user or not user.totp_secret_enc:
|
||||||
|
raise HTTPException(422, "Run /setup first to generate a secret")
|
||||||
|
secret = decrypt_secret(user.totp_secret_enc)
|
||||||
|
if not pyotp.TOTP(secret).verify(req.otp_code.strip(), valid_window=1):
|
||||||
|
raise HTTPException(422, "Invalid OTP code — make sure your authenticator is in sync")
|
||||||
|
user.totp_enabled = True
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{username}/totp", status_code=204)
|
||||||
|
async def totp_disable(
|
||||||
|
username: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
caller: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""Disable 2FA and discard the stored secret."""
|
||||||
|
if caller["sub"] != username:
|
||||||
|
raise HTTPException(403, "You can only configure 2FA for your own account")
|
||||||
|
user: Optional[AdminUser] = db.query(AdminUser).filter_by(username=username).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(404, "Admin not found")
|
||||||
|
user.totp_enabled = False
|
||||||
|
user.totp_secret_enc = None
|
||||||
|
db.commit()
|
||||||
|
|||||||
@@ -1,28 +1,43 @@
|
|||||||
from datetime import datetime, timedelta
|
import logging
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
import pyotp
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from core.auth import create_token, verify_password
|
from core.auth import create_token, decrypt_secret, verify_password
|
||||||
|
from core.config import utcnow
|
||||||
from core.database import AdminUser, Keypass, get_db
|
from core.database import AdminUser, Keypass, get_db
|
||||||
from core.schemas import AdminLoginRequest, KeypassLoginRequest, TokenResponse
|
from core.schemas import AdminLoginRequest, AdminLoginResponse, KeypassLoginRequest, TokenResponse
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/admin", response_model=TokenResponse)
|
@router.post("/admin", response_model=AdminLoginResponse)
|
||||||
async def admin_login(req: AdminLoginRequest, db: Session = Depends(get_db)):
|
async def admin_login(req: AdminLoginRequest, db: Session = Depends(get_db)):
|
||||||
user: Optional[AdminUser] = db.query(AdminUser).filter_by(username=req.username).first()
|
user: Optional[AdminUser] = db.query(AdminUser).filter_by(username=req.username).first()
|
||||||
if not user or not verify_password(req.password, user.password_hash):
|
if not user or not verify_password(req.password, user.password_hash):
|
||||||
|
logger.warning("Failed admin login attempt for username=%r", req.username)
|
||||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid credentials")
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid credentials")
|
||||||
|
|
||||||
|
if user.totp_enabled and user.totp_secret_enc:
|
||||||
|
if not req.otp_code:
|
||||||
|
return AdminLoginResponse(otp_required=True)
|
||||||
|
secret = decrypt_secret(user.totp_secret_enc)
|
||||||
|
totp = pyotp.TOTP(secret)
|
||||||
|
if not totp.verify(req.otp_code.strip(), valid_window=1):
|
||||||
|
logger.warning("Invalid OTP for username=%r", user.username)
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid OTP code")
|
||||||
|
|
||||||
|
logger.info("Admin login: username=%r role=%r", user.username, user.role)
|
||||||
token = create_token({
|
token = create_token({
|
||||||
"sub": user.username,
|
"sub": user.username,
|
||||||
"role": "admin",
|
"role": "admin",
|
||||||
"scope": user.role, # 'admin' | 'manager'
|
"scope": user.role,
|
||||||
"exp": datetime.utcnow() + timedelta(hours=24),
|
"exp": datetime.now(timezone.utc) + timedelta(hours=24),
|
||||||
})
|
})
|
||||||
return TokenResponse(token=token)
|
return AdminLoginResponse(token=token)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/keypass", response_model=TokenResponse)
|
@router.post("/keypass", response_model=TokenResponse)
|
||||||
@@ -34,9 +49,9 @@ async def keypass_login(req: KeypassLoginRequest, db: Session = Depends(get_db))
|
|||||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid keypass")
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid keypass")
|
||||||
if kp.revoked:
|
if kp.revoked:
|
||||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass has been revoked")
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass has been revoked")
|
||||||
if kp.expires_at is not None and kp.expires_at < datetime.utcnow():
|
if kp.expires_at is not None and kp.expires_at < utcnow():
|
||||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass has expired")
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass has expired")
|
||||||
exp = kp.expires_at if kp.expires_at else datetime(2099, 12, 31, 23, 59, 59)
|
exp = kp.expires_at if kp.expires_at else datetime(2099, 12, 31, 23, 59, 59, tzinfo=timezone.utc)
|
||||||
token = create_token({
|
token = create_token({
|
||||||
"sub": str(kp.id),
|
"sub": str(kp.id),
|
||||||
"role": "keypass",
|
"role": "keypass",
|
||||||
|
|||||||
@@ -1,40 +1,125 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from core.auth import encrypt_secret
|
from core.auth import encrypt_secret
|
||||||
from core.database import ApiCredential, get_db
|
from core.database import ApiCredential, ShellyCredential, get_db
|
||||||
from core.dependencies import require_admin
|
from core.dependencies import require_admin
|
||||||
from core.schemas import CredentialRead, CredentialUpsert
|
from core.schemas import CredentialRead, CredentialUpsert, ShellyCredentialRead, ShellyCredentialUpsert
|
||||||
|
from services.avconnect import AVConnectAPI
|
||||||
|
from services.shelly import ShellyCloudAPI
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin/credentials", tags=["admin-credentials"])
|
router = APIRouter(prefix="/api/admin/credentials", tags=["admin-credentials"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[CredentialRead])
|
# ── AVConnect credentials ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/avconnect", response_model=list[CredentialRead])
|
||||||
async def list_credentials(
|
async def list_credentials(
|
||||||
db: Session = Depends(get_db), _: dict = Depends(require_admin)
|
db: Session = Depends(get_db), _: dict = Depends(require_admin)
|
||||||
):
|
):
|
||||||
return [CredentialRead(id=c.id, username=c.username) for c in db.query(ApiCredential).all()]
|
return [CredentialRead(id=c.id, username=c.username) for c in db.query(ApiCredential).all()]
|
||||||
|
|
||||||
|
|
||||||
@router.put("", response_model=CredentialRead)
|
@router.put("/avconnect", response_model=CredentialRead)
|
||||||
async def upsert_credential(
|
async def upsert_credential(
|
||||||
req: CredentialUpsert,
|
req: CredentialUpsert,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: dict = Depends(require_admin),
|
_: dict = Depends(require_admin),
|
||||||
):
|
):
|
||||||
|
try:
|
||||||
|
ok, session_id = AVConnectAPI(req.username, req.password).validate_credentials()
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(502, f"Could not reach AVConnect: {exc}")
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(422, "AVConnect rejected these credentials")
|
||||||
|
|
||||||
cred: Optional[ApiCredential] = db.query(ApiCredential).first()
|
cred: Optional[ApiCredential] = db.query(ApiCredential).first()
|
||||||
if cred:
|
if cred:
|
||||||
cred.username = req.username
|
cred.username = req.username
|
||||||
cred.password_enc = encrypt_secret(req.password)
|
cred.password_enc = encrypt_secret(req.password)
|
||||||
cred.session_id = None # invalidate any cached session
|
cred.session_id = session_id
|
||||||
else:
|
else:
|
||||||
cred = ApiCredential(
|
cred = ApiCredential(
|
||||||
username=req.username,
|
username=req.username,
|
||||||
password_enc=encrypt_secret(req.password),
|
password_enc=encrypt_secret(req.password),
|
||||||
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
db.add(cred)
|
db.add(cred)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(cred)
|
db.refresh(cred)
|
||||||
return CredentialRead(id=cred.id, username=cred.username)
|
return CredentialRead(id=cred.id, username=cred.username)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Shelly Cloud credentials ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/shelly", response_model=Optional[ShellyCredentialRead])
|
||||||
|
async def get_shelly_credential(
|
||||||
|
db: Session = Depends(get_db), _: dict = Depends(require_admin)
|
||||||
|
):
|
||||||
|
cred: Optional[ShellyCredential] = db.query(ShellyCredential).first()
|
||||||
|
if not cred:
|
||||||
|
return None
|
||||||
|
return ShellyCredentialRead(id=cred.id, server_uri=cred.server_uri)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/shelly", response_model=ShellyCredentialRead)
|
||||||
|
async def upsert_shelly_credential(
|
||||||
|
req: ShellyCredentialUpsert,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
ok = ShellyCloudAPI(req.server_uri, req.auth_key).validate_credentials()
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(502, f"Could not reach Shelly Cloud: {exc}")
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(422, "Shelly Cloud rejected these credentials")
|
||||||
|
|
||||||
|
cred: Optional[ShellyCredential] = db.query(ShellyCredential).first()
|
||||||
|
if cred:
|
||||||
|
cred.server_uri = req.server_uri
|
||||||
|
cred.auth_key_enc = encrypt_secret(req.auth_key)
|
||||||
|
else:
|
||||||
|
cred = ShellyCredential(
|
||||||
|
server_uri=req.server_uri,
|
||||||
|
auth_key_enc=encrypt_secret(req.auth_key),
|
||||||
|
)
|
||||||
|
db.add(cred)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(cred)
|
||||||
|
return ShellyCredentialRead(id=cred.id, server_uri=cred.server_uri)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Mock mode setting ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class MockSettingResponse(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
class MockSettingRequest(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mock", response_model=MockSettingResponse)
|
||||||
|
async def get_mock_setting(
|
||||||
|
db: Session = Depends(get_db), _: dict = Depends(require_admin)
|
||||||
|
):
|
||||||
|
cred: Optional[ApiCredential] = db.query(ApiCredential).first()
|
||||||
|
return MockSettingResponse(enabled=bool(cred.mock_avconnect) if cred else False)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/mock", response_model=MockSettingResponse)
|
||||||
|
async def set_mock_setting(
|
||||||
|
req: MockSettingRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
cred: Optional[ApiCredential] = db.query(ApiCredential).first()
|
||||||
|
if not cred:
|
||||||
|
raise HTTPException(503, "AVConnect credentials not configured")
|
||||||
|
cred.mock_avconnect = req.enabled
|
||||||
|
db.commit()
|
||||||
|
return MockSettingResponse(enabled=req.enabled)
|
||||||
|
|||||||
@@ -1,18 +1,81 @@
|
|||||||
import json
|
import json
|
||||||
from datetime import datetime
|
import logging
|
||||||
|
import threading
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from core.auth import decrypt_secret
|
from core.auth import decrypt_secret
|
||||||
from core.database import ApiCredential, GateAccessLog, GateDB, Keypass, get_db
|
from core.config import utcnow
|
||||||
|
from core.database import ApiCredential, GateAccessLog, GateDB, Keypass, ShellyCredential, TelegramConfig, get_db
|
||||||
from core.dependencies import require_admin, require_manager, require_keypass
|
from core.dependencies import require_admin, require_manager, require_keypass
|
||||||
from models import Credential, Gate as GateModel, Status
|
from core.schemas import GateCreate, GatePublicResponse, GateResponse
|
||||||
from core.schemas import GateCreate, GateResponse
|
|
||||||
from services.gates import call_open_gate
|
from services.gates import call_open_gate
|
||||||
|
from services.telegram import send_gate_notification
|
||||||
|
|
||||||
router = APIRouter(tags=["gates"])
|
router = APIRouter(tags=["gates"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _notify(db: Session, gate_name: str, opened_by: str, ip: str | None) -> None:
|
||||||
|
"""Fire a Telegram notification in a background thread if configured and enabled."""
|
||||||
|
try:
|
||||||
|
cfg: Optional[TelegramConfig] = db.query(TelegramConfig).first()
|
||||||
|
if not cfg or not cfg.enabled:
|
||||||
|
return
|
||||||
|
token = decrypt_secret(cfg.bot_token_enc)
|
||||||
|
chat_id = cfg.chat_id
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
threading.Thread(
|
||||||
|
target=send_gate_notification,
|
||||||
|
args=(token, chat_id, gate_name, opened_by, ip),
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_gate_create(req: GateCreate) -> None:
|
||||||
|
if not req.gate_icon:
|
||||||
|
raise HTTPException(400, "gate_icon must not be empty")
|
||||||
|
if req.api_provider not in ("avconnect", "shelly"):
|
||||||
|
raise HTTPException(400, "api_provider must be 'avconnect' or 'shelly'")
|
||||||
|
if req.api_provider == "avconnect" and not req.avconnect_macro_id:
|
||||||
|
raise HTTPException(400, "avconnect_macro_id is required for AVConnect gates")
|
||||||
|
if req.api_provider == "shelly" and not req.shelly_device_id:
|
||||||
|
raise HTTPException(400, "shelly_device_id is required for Shelly gates")
|
||||||
|
|
||||||
|
|
||||||
|
def _do_open_gate(
|
||||||
|
gate_db: GateDB,
|
||||||
|
db: Session,
|
||||||
|
mock: bool,
|
||||||
|
) -> tuple[bool, Optional[str], Optional[str]]:
|
||||||
|
"""Dispatch the gate-open call to the appropriate API provider."""
|
||||||
|
if gate_db.api_provider == "shelly":
|
||||||
|
shelly_cred: Optional[ShellyCredential] = db.query(ShellyCredential).first()
|
||||||
|
if not shelly_cred:
|
||||||
|
raise HTTPException(503, "Shelly credentials not configured")
|
||||||
|
return call_open_gate(
|
||||||
|
api_provider="shelly",
|
||||||
|
shelly_device_id=gate_db.shelly_device_id,
|
||||||
|
shelly_server_uri=shelly_cred.server_uri,
|
||||||
|
shelly_auth_key=decrypt_secret(shelly_cred.auth_key_enc),
|
||||||
|
mock=mock,
|
||||||
|
)
|
||||||
|
|
||||||
|
# AVConnect (default)
|
||||||
|
av_cred: Optional[ApiCredential] = db.query(ApiCredential).first()
|
||||||
|
if not av_cred:
|
||||||
|
raise HTTPException(503, "AVConnect credentials not configured")
|
||||||
|
return call_open_gate(
|
||||||
|
api_provider="avconnect",
|
||||||
|
avconnect_macro_id=gate_db.avconnect_macro_id,
|
||||||
|
avconnect_username=av_cred.username,
|
||||||
|
avconnect_password=decrypt_secret(av_cred.password_enc),
|
||||||
|
avconnect_session_id=av_cred.session_id,
|
||||||
|
mock=mock,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Admin: gate CRUD ──────────────────────────────────────────────────────────
|
# ── Admin: gate CRUD ──────────────────────────────────────────────────────────
|
||||||
@@ -30,8 +93,7 @@ async def create_gate(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: dict = Depends(require_admin),
|
_: dict = Depends(require_admin),
|
||||||
):
|
):
|
||||||
if req.gate_type not in ("car", "pedestrian"):
|
_validate_gate_create(req)
|
||||||
raise HTTPException(400, "gate_type must be 'car' or 'pedestrian'")
|
|
||||||
gate = GateDB(**req.model_dump())
|
gate = GateDB(**req.model_dump())
|
||||||
db.add(gate)
|
db.add(gate)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -46,6 +108,7 @@ async def update_gate(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: dict = Depends(require_admin),
|
_: dict = Depends(require_admin),
|
||||||
):
|
):
|
||||||
|
_validate_gate_create(req)
|
||||||
gate: Optional[GateDB] = db.query(GateDB).filter(GateDB.id == gate_id).first()
|
gate: Optional[GateDB] = db.query(GateDB).filter(GateDB.id == gate_id).first()
|
||||||
if not gate:
|
if not gate:
|
||||||
raise HTTPException(404, "Gate not found")
|
raise HTTPException(404, "Gate not found")
|
||||||
@@ -82,24 +145,17 @@ async def admin_open_gate(
|
|||||||
if gate_db.status != "enabled":
|
if gate_db.status != "enabled":
|
||||||
raise HTTPException(409, "Gate is disabled")
|
raise HTTPException(409, "Gate is disabled")
|
||||||
|
|
||||||
cred_db: Optional[ApiCredential] = db.query(ApiCredential).first()
|
# Determine mock mode from AVConnect credential record (applies to all providers)
|
||||||
if not cred_db:
|
av_cred: Optional[ApiCredential] = db.query(ApiCredential).first()
|
||||||
raise HTTPException(503, "AVConnect credentials not configured")
|
mock = bool(av_cred.mock_avconnect) if av_cred else False
|
||||||
|
|
||||||
credential = Credential(
|
ip = request.client.host if request.client else None
|
||||||
username=cred_db.username,
|
|
||||||
password=decrypt_secret(cred_db.password_enc),
|
|
||||||
)
|
|
||||||
credential.sessionid = cred_db.session_id
|
|
||||||
|
|
||||||
gate = GateModel(id=gate_db.avconnect_macro_id, name=gate_db.name, status=Status.ENABLED)
|
|
||||||
ip = request.headers.get("X-Forwarded-For", request.client.host if request.client else None)
|
|
||||||
ua = request.headers.get("User-Agent")
|
ua = request.headers.get("User-Agent")
|
||||||
|
|
||||||
success, error_msg, new_sid = call_open_gate(gate, credential)
|
success, error_msg, new_sid = _do_open_gate(gate_db, db, mock)
|
||||||
|
|
||||||
db.add(GateAccessLog(
|
db.add(GateAccessLog(
|
||||||
timestamp=datetime.utcnow(),
|
timestamp=utcnow(),
|
||||||
keypass_id=0,
|
keypass_id=0,
|
||||||
keypass_code=f"[{caller['sub']}]",
|
keypass_code=f"[{caller['sub']}]",
|
||||||
gate_id=gate_db.id,
|
gate_id=gate_db.id,
|
||||||
@@ -110,19 +166,22 @@ async def admin_open_gate(
|
|||||||
error=error_msg,
|
error=error_msg,
|
||||||
))
|
))
|
||||||
|
|
||||||
if new_sid and new_sid != cred_db.session_id:
|
if new_sid and av_cred and new_sid != av_cred.session_id:
|
||||||
cred_db.session_id = new_sid
|
av_cred.session_id = new_sid
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
|
logger.error("Gate open failed: gate_id=%d caller=%r error=%r", gate_db.id, caller["sub"], error_msg)
|
||||||
raise HTTPException(502, error_msg or "Gate operation failed")
|
raise HTTPException(502, error_msg or "Gate operation failed")
|
||||||
|
|
||||||
|
logger.info("Gate opened by admin: gate_id=%d gate=%r caller=%r ip=%r", gate_db.id, gate_db.name, caller["sub"], ip)
|
||||||
|
_notify(db, gate_db.name, f"[{caller['sub']}]", ip)
|
||||||
return {"success": True, "gate": gate_db.name}
|
return {"success": True, "gate": gate_db.name}
|
||||||
|
|
||||||
|
|
||||||
# ── User-facing gate routes ───────────────────────────────────────────────────
|
# ── User-facing gate routes ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/api/gates", response_model=list[GateResponse])
|
@router.get("/api/gates", response_model=list[GatePublicResponse])
|
||||||
async def list_gates(
|
async def list_gates(
|
||||||
db: Session = Depends(get_db), _kp: Keypass = Depends(require_keypass)
|
db: Session = Depends(get_db), _kp: Keypass = Depends(require_keypass)
|
||||||
):
|
):
|
||||||
@@ -146,29 +205,20 @@ async def open_gate(
|
|||||||
if not gate_db:
|
if not gate_db:
|
||||||
raise HTTPException(404, "Gate not found or disabled")
|
raise HTTPException(404, "Gate not found or disabled")
|
||||||
|
|
||||||
cred_db: Optional[ApiCredential] = db.query(ApiCredential).first()
|
|
||||||
if not cred_db:
|
|
||||||
raise HTTPException(503, "AVConnect credentials not configured")
|
|
||||||
|
|
||||||
credential = Credential(
|
|
||||||
username=cred_db.username,
|
|
||||||
password=decrypt_secret(cred_db.password_enc),
|
|
||||||
)
|
|
||||||
credential.sessionid = cred_db.session_id
|
|
||||||
|
|
||||||
gate_status = Status.ENABLED if gate_db.status == "enabled" else Status.DISABLED
|
|
||||||
gate = GateModel(id=gate_db.avconnect_macro_id, name=gate_db.name, status=gate_status)
|
|
||||||
ip = request.headers.get("X-Forwarded-For", request.client.host if request.client else None)
|
|
||||||
ua = request.headers.get("User-Agent")
|
|
||||||
|
|
||||||
allowed = json.loads(_kp.allowed_gates) if _kp.allowed_gates else None
|
allowed = json.loads(_kp.allowed_gates) if _kp.allowed_gates else None
|
||||||
if allowed is not None and gate_id not in allowed:
|
if allowed is not None and gate_id not in allowed:
|
||||||
raise HTTPException(403, "This keypass does not have access to this gate")
|
raise HTTPException(403, "This keypass does not have access to this gate")
|
||||||
|
|
||||||
success, error_msg, new_sid = call_open_gate(gate, credential)
|
av_cred: Optional[ApiCredential] = db.query(ApiCredential).first()
|
||||||
|
mock = bool(av_cred.mock_avconnect) if av_cred else False
|
||||||
|
|
||||||
|
ip = request.client.host if request.client else None
|
||||||
|
ua = request.headers.get("User-Agent")
|
||||||
|
|
||||||
|
success, error_msg, new_sid = _do_open_gate(gate_db, db, mock)
|
||||||
|
|
||||||
db.add(GateAccessLog(
|
db.add(GateAccessLog(
|
||||||
timestamp=datetime.utcnow(),
|
timestamp=utcnow(),
|
||||||
keypass_id=_kp.id,
|
keypass_id=_kp.id,
|
||||||
keypass_code=_kp.code,
|
keypass_code=_kp.code,
|
||||||
gate_id=gate_db.id,
|
gate_id=gate_db.id,
|
||||||
@@ -179,11 +229,14 @@ async def open_gate(
|
|||||||
error=error_msg,
|
error=error_msg,
|
||||||
))
|
))
|
||||||
|
|
||||||
if new_sid and new_sid != cred_db.session_id:
|
if new_sid and av_cred and new_sid != av_cred.session_id:
|
||||||
cred_db.session_id = new_sid
|
av_cred.session_id = new_sid
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
|
logger.error("Gate open failed: gate_id=%d keypass_id=%d error=%r", gate_db.id, _kp.id, error_msg)
|
||||||
raise HTTPException(502, error_msg or "Gate operation failed")
|
raise HTTPException(502, error_msg or "Gate operation failed")
|
||||||
|
|
||||||
|
logger.info("Gate opened by keypass: gate_id=%d gate=%r keypass=%r ip=%r", gate_db.id, gate_db.name, f"{_kp.description} ({_kp.code})", ip)
|
||||||
|
_notify(db, gate_db.name, f"{_kp.description}", ip)
|
||||||
return {"success": True, "gate": gate_db.name}
|
return {"success": True, "gate": gate_db.name}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
import string
|
import string
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
import qrcode
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from core.config import utcnow
|
||||||
from core.database import Keypass, get_db
|
from core.database import Keypass, get_db
|
||||||
from core.dependencies import require_manager
|
from core.dependencies import require_manager
|
||||||
from core.schemas import KeypassCreate, KeypassPatch, KeypassResponse, keypass_to_response
|
from core.schemas import KeypassCreate, KeypassPatch, KeypassResponse, keypass_to_response
|
||||||
@@ -14,8 +17,75 @@ from core.schemas import KeypassCreate, KeypassPatch, KeypassResponse, keypass_t
|
|||||||
router = APIRouter(prefix="/api/admin/keypasses", tags=["admin-keypasses"])
|
router = APIRouter(prefix="/api/admin/keypasses", tags=["admin-keypasses"])
|
||||||
|
|
||||||
|
|
||||||
def _generate_code(length: int = 12) -> str:
|
def _serialize_schedule(s) -> Optional[str]:
|
||||||
alphabet = string.ascii_uppercase + string.digits
|
"""Serialize a ScheduleRule to a JSON string, or None if effectively empty."""
|
||||||
|
if s is None:
|
||||||
|
return None
|
||||||
|
d = s.model_dump(exclude_none=True)
|
||||||
|
if "days" in d and not d["days"]:
|
||||||
|
del d["days"]
|
||||||
|
return json.dumps(d) if d else None
|
||||||
|
|
||||||
|
# ── Word list for passphrase mode ─────────────────────────────────────────────
|
||||||
|
_WORDS = [
|
||||||
|
"apple", "beach", "brick", "brush", "cabin", "calm", "cedar", "chain",
|
||||||
|
"chalk", "chase", "chest", "clear", "cliff", "clock", "cloud", "coast",
|
||||||
|
"coral", "crane", "creek", "crisp", "crown", "curve", "dance", "delta",
|
||||||
|
"depot", "drift", "drive", "drops", "dunes", "eagle", "earth", "ember",
|
||||||
|
"fence", "field", "final", "flame", "flash", "fleet", "flint", "floor",
|
||||||
|
"focus", "forge", "forth", "frost", "fruit", "glade", "gleam", "globe",
|
||||||
|
"gloom", "gloss", "grace", "grain", "grand", "grape", "grass", "gravel",
|
||||||
|
"green", "grove", "guard", "guide", "haven", "heart", "honey", "honor",
|
||||||
|
"hatch", "image", "inlet", "ivory", "joker", "karma", "knoll", "lake",
|
||||||
|
"lance", "large", "laser", "latch", "layer", "ledge", "light", "linen",
|
||||||
|
"links", "liver", "lunar", "mango", "maple", "march", "marsh", "merit",
|
||||||
|
"metal", "minor", "mirth", "misty", "mixer", "mocha", "model", "money",
|
||||||
|
"mount", "mouse", "named", "nerve", "night", "noble", "north", "notch",
|
||||||
|
"novel", "oaken", "ocean", "olive", "ombre", "ozone", "panel", "paper",
|
||||||
|
"patch", "pause", "peace", "pearl", "petal", "pilot", "pinch", "pixel",
|
||||||
|
"plain", "plane", "plaza", "plumb", "plume", "plush", "polar", "porch",
|
||||||
|
"power", "prism", "prize", "probe", "proxy", "pulse", "quail", "quartz",
|
||||||
|
"queen", "quest", "queue", "quiet", "quota", "quote", "radar", "radix",
|
||||||
|
"rally", "ranch", "rapid", "raven", "reach", "realm", "relay", "repay",
|
||||||
|
"resin", "ridge", "rivet", "river", "rogue", "round", "route", "rover",
|
||||||
|
"royal", "rusty", "saint", "salsa", "salvo", "sandy", "score", "scout",
|
||||||
|
"serum", "shade", "shaft", "shale", "shall", "shape", "sharp", "sheen",
|
||||||
|
"shelf", "shell", "shift", "shiny", "shore", "short", "sigma", "silky",
|
||||||
|
"silva", "since", "sixth", "slate", "slope", "smoke", "solar", "solid",
|
||||||
|
"sonic", "sound", "south", "space", "spark", "spear", "spend", "spire",
|
||||||
|
"split", "spoke", "spore", "sport", "spray", "spree", "sprig", "squad",
|
||||||
|
"stack", "staff", "stage", "stain", "stake", "stale", "stall", "stamp",
|
||||||
|
"stand", "stark", "start", "state", "stays", "steel", "steep", "steer",
|
||||||
|
"stern", "stock", "stone", "storm", "story", "stove", "strap", "straw",
|
||||||
|
"strip", "strut", "study", "stuff", "sugar", "suite", "sunny", "surge",
|
||||||
|
"swamp", "swarm", "swept", "swing", "sword", "synth", "table", "talon",
|
||||||
|
"tango", "taste", "tawny", "tempo", "thatch", "theme", "think", "thorn",
|
||||||
|
"three", "threw", "tiger", "tidal", "titan", "token", "topaz", "torch",
|
||||||
|
"total", "touch", "tough", "towel", "tower", "trace", "track", "trade",
|
||||||
|
"trail", "train", "trait", "tramp", "trawl", "trend", "trial", "tribe",
|
||||||
|
"trick", "tried", "troop", "trove", "truce", "trunk", "trust", "tuber",
|
||||||
|
"tundra", "tuner", "twill", "twist", "ultra", "umbra", "uncle", "union",
|
||||||
|
"unity", "until", "upper", "urged", "usage", "utmost", "valor", "vault",
|
||||||
|
"vibes", "vigor", "viper", "vista", "vital", "vivid", "voice", "voter",
|
||||||
|
"vroom", "waltz", "water", "waves", "wedge", "weird", "wheat", "wheel",
|
||||||
|
"where", "whirl", "white", "whole", "widow", "width", "winds", "windy",
|
||||||
|
"witch", "world", "wreck", "wrist", "xerox", "yacht", "yards", "years",
|
||||||
|
"yield", "young", "youth", "zeal", "zebra", "zones", "zenith",
|
||||||
|
]
|
||||||
|
|
||||||
|
_CHARSETS = {
|
||||||
|
"alphanumeric": string.ascii_uppercase + string.digits,
|
||||||
|
"alpha": string.ascii_uppercase,
|
||||||
|
"numeric": string.digits,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_code(length: int = 12, charset: str = "alphanumeric") -> str:
|
||||||
|
if charset == "passphrase":
|
||||||
|
# 4 random words joined by hyphens; `length` is ignored for passphrases
|
||||||
|
return "-".join(secrets.choice(_WORDS).upper() for _ in range(4))
|
||||||
|
alphabet = _CHARSETS.get(charset, _CHARSETS["alphanumeric"])
|
||||||
|
length = max(6, min(length, 32))
|
||||||
return "".join(secrets.choice(alphabet) for _ in range(length))
|
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||||
|
|
||||||
|
|
||||||
@@ -32,16 +102,17 @@ async def create_keypass(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: dict = Depends(require_manager),
|
_: dict = Depends(require_manager),
|
||||||
):
|
):
|
||||||
code = req.code.strip().upper() if req.code and req.code.strip() else _generate_code()
|
code = req.code.strip().upper() if req.code and req.code.strip() else _generate_code(req.length, req.charset)
|
||||||
if db.query(Keypass).filter(Keypass.code == code).first():
|
if db.query(Keypass).filter(Keypass.code == code).first():
|
||||||
raise HTTPException(409, "A keypass with this code already exists")
|
raise HTTPException(409, "A keypass with this code already exists")
|
||||||
kp = Keypass(
|
kp = Keypass(
|
||||||
code=code,
|
code=code,
|
||||||
description=req.description,
|
description=req.description,
|
||||||
created_at=datetime.utcnow(),
|
created_at=utcnow(),
|
||||||
expires_at=req.expires_at,
|
expires_at=req.expires_at,
|
||||||
revoked=False,
|
revoked=False,
|
||||||
allowed_gates=json.dumps(req.gate_ids) if req.gate_ids else None,
|
allowed_gates=json.dumps(req.gate_ids) if req.gate_ids else None,
|
||||||
|
schedule=_serialize_schedule(req.schedule),
|
||||||
)
|
)
|
||||||
db.add(kp)
|
db.add(kp)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -66,6 +137,8 @@ async def update_keypass(
|
|||||||
kp.expires_at = req.expires_at
|
kp.expires_at = req.expires_at
|
||||||
if req.gate_ids is not None:
|
if req.gate_ids is not None:
|
||||||
kp.allowed_gates = json.dumps(req.gate_ids) if req.gate_ids else None
|
kp.allowed_gates = json.dumps(req.gate_ids) if req.gate_ids else None
|
||||||
|
if "schedule" in req.model_fields_set:
|
||||||
|
kp.schedule = _serialize_schedule(req.schedule)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(kp)
|
db.refresh(kp)
|
||||||
return keypass_to_response(kp)
|
return keypass_to_response(kp)
|
||||||
@@ -80,10 +153,35 @@ async def revoke_keypass(
|
|||||||
kp: Optional[Keypass] = db.query(Keypass).filter(Keypass.id == kp_id).first()
|
kp: Optional[Keypass] = db.query(Keypass).filter(Keypass.id == kp_id).first()
|
||||||
if not kp:
|
if not kp:
|
||||||
raise HTTPException(404, "Keypass not found")
|
raise HTTPException(404, "Keypass not found")
|
||||||
if kp.expires_at is not None and kp.expires_at < datetime.utcnow():
|
if kp.expires_at is not None and kp.expires_at < utcnow():
|
||||||
raise HTTPException(409, "Expired keypasses cannot be revoked")
|
raise HTTPException(409, "Expired keypasses cannot be revoked")
|
||||||
if kp.revoked:
|
if kp.revoked:
|
||||||
raise HTTPException(409, "Keypass is already revoked")
|
raise HTTPException(409, "Keypass is already revoked")
|
||||||
kp.revoked = True
|
kp.revoked = True
|
||||||
kp.revoked_at = datetime.utcnow()
|
kp.revoked_at = utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{kp_id}/qr")
|
||||||
|
async def keypass_qr(
|
||||||
|
kp_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_manager),
|
||||||
|
):
|
||||||
|
kp: Optional[Keypass] = db.query(Keypass).filter(Keypass.id == kp_id).first()
|
||||||
|
if not kp:
|
||||||
|
raise HTTPException(404, "Keypass not found")
|
||||||
|
|
||||||
|
base_url = str(request.base_url).rstrip("/")
|
||||||
|
url = f"{base_url}/?k={kp.code}"
|
||||||
|
|
||||||
|
qr = qrcode.QRCode(box_size=10, border=4)
|
||||||
|
qr.add_data(url)
|
||||||
|
qr.make(fit=True)
|
||||||
|
img = qr.make_image(fill_color="black", back_color="white")
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf)
|
||||||
|
buf.seek(0)
|
||||||
|
return Response(content=buf.read(), media_type="image/png")
|
||||||
|
|||||||
81
src/routers/logs.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from core.config import LOG_FILE
|
||||||
|
from core.dependencies import require_admin
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/admin/logs", tags=["admin-logs"])
|
||||||
|
|
||||||
|
_LEVEL_ORDER = {"DEBUG": 0, "INFO": 1, "WARNING": 2, "ERROR": 3, "CRITICAL": 4}
|
||||||
|
|
||||||
|
|
||||||
|
class LogsResponse(BaseModel):
|
||||||
|
lines: list[str]
|
||||||
|
log_file: Optional[str]
|
||||||
|
total_returned: int
|
||||||
|
|
||||||
|
|
||||||
|
def _read_tail(path: str, n: int) -> list[str]:
|
||||||
|
"""Return the last *n* lines from *path* using a memory-efficient seek."""
|
||||||
|
size = os.path.getsize(path)
|
||||||
|
if size == 0:
|
||||||
|
return []
|
||||||
|
# Read in chunks from the end
|
||||||
|
chunk = 1 << 14 # 16 KB
|
||||||
|
lines: list[bytes] = []
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
pos = size
|
||||||
|
while len(lines) <= n and pos > 0:
|
||||||
|
pos = max(pos - chunk, 0)
|
||||||
|
fh.seek(pos)
|
||||||
|
data = fh.read(min(chunk, size - pos))
|
||||||
|
lines = data.split(b"\n") + lines
|
||||||
|
# If we have more lines than needed, trim from the front
|
||||||
|
if len(lines) > n + 1:
|
||||||
|
lines = lines[-(n + 1):]
|
||||||
|
# Drop empty last element caused by trailing newline
|
||||||
|
result = [ln.decode("utf-8", errors="replace") for ln in lines if ln]
|
||||||
|
return result[-n:] if len(result) > n else result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=LogsResponse)
|
||||||
|
async def get_logs(
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
lines: int = Query(200, ge=1, le=2000, description="Number of lines to return (tail)"),
|
||||||
|
level: Optional[str] = Query(None, description="Minimum log level: DEBUG, INFO, WARNING, ERROR, CRITICAL"),
|
||||||
|
):
|
||||||
|
if not LOG_FILE:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Log file not configured (LOG_FILE env variable is not set).",
|
||||||
|
)
|
||||||
|
if not os.path.isfile(LOG_FILE):
|
||||||
|
return LogsResponse(lines=[], log_file=LOG_FILE, total_returned=0)
|
||||||
|
|
||||||
|
raw = _read_tail(LOG_FILE, lines)
|
||||||
|
|
||||||
|
if level:
|
||||||
|
level_upper = level.upper()
|
||||||
|
if level_upper not in _LEVEL_ORDER:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"Invalid level '{level}'. Must be one of: {', '.join(_LEVEL_ORDER)}.",
|
||||||
|
)
|
||||||
|
min_order = _LEVEL_ORDER[level_upper]
|
||||||
|
filtered: list[str] = []
|
||||||
|
for line in raw:
|
||||||
|
for lvl, order in _LEVEL_ORDER.items():
|
||||||
|
if lvl in line and order >= min_order:
|
||||||
|
filtered.append(line)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# Lines that don't match any known level keyword are included
|
||||||
|
# only when filtering at DEBUG (include everything)
|
||||||
|
if min_order == 0:
|
||||||
|
filtered.append(line)
|
||||||
|
raw = filtered
|
||||||
|
|
||||||
|
return LogsResponse(lines=raw, log_file=LOG_FILE, total_returned=len(raw))
|
||||||
@@ -1,21 +1,45 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from core.database import GateAccessLog, get_db
|
from core.database import GateAccessLog, get_db
|
||||||
from core.dependencies import require_manager
|
from core.dependencies import require_manager
|
||||||
from core.schemas import AccessLogResponse
|
from core.schemas import StatsPage
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin/stats", tags=["admin-stats"])
|
router = APIRouter(prefix="/api/admin/stats", tags=["admin-stats"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[AccessLogResponse])
|
@router.get("", response_model=StatsPage)
|
||||||
async def get_stats(
|
async def get_stats(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: dict = Depends(require_manager),
|
_: dict = Depends(require_manager),
|
||||||
|
gate_id: Optional[int] = Query(None),
|
||||||
|
keypass_code: Optional[str] = Query(None),
|
||||||
|
success: Optional[bool] = Query(None),
|
||||||
|
date_from: Optional[datetime] = Query(None),
|
||||||
|
date_to: Optional[datetime] = Query(None),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(50, ge=1, le=200),
|
||||||
):
|
):
|
||||||
return (
|
q = db.query(GateAccessLog)
|
||||||
db.query(GateAccessLog)
|
if gate_id is not None:
|
||||||
.order_by(GateAccessLog.timestamp.desc())
|
q = q.filter(GateAccessLog.gate_id == gate_id)
|
||||||
.limit(500)
|
if keypass_code:
|
||||||
|
q = q.filter(GateAccessLog.keypass_code.ilike(f"%{keypass_code}%"))
|
||||||
|
if success is not None:
|
||||||
|
q = q.filter(GateAccessLog.success == success)
|
||||||
|
if date_from is not None:
|
||||||
|
q = q.filter(GateAccessLog.timestamp >= date_from)
|
||||||
|
if date_to is not None:
|
||||||
|
q = q.filter(GateAccessLog.timestamp <= date_to)
|
||||||
|
|
||||||
|
total = q.count()
|
||||||
|
items = (
|
||||||
|
q.order_by(GateAccessLog.timestamp.desc())
|
||||||
|
.offset((page - 1) * page_size)
|
||||||
|
.limit(page_size)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
return StatsPage(total=total, page=page, page_size=page_size, items=items)
|
||||||
|
|||||||
75
src/routers/telegram.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from core.auth import decrypt_secret, encrypt_secret
|
||||||
|
from core.database import TelegramConfig, get_db
|
||||||
|
from core.dependencies import require_admin
|
||||||
|
from services.telegram import send_test_message
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/admin/telegram", tags=["admin-telegram"])
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramConfigRead(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
chat_id: str
|
||||||
|
configured: bool # True when a bot token has been saved
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramConfigUpsert(BaseModel):
|
||||||
|
bot_token: Optional[str] = None # None = keep existing token
|
||||||
|
chat_id: str
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=TelegramConfigRead)
|
||||||
|
async def get_telegram_config(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
cfg: Optional[TelegramConfig] = db.query(TelegramConfig).first()
|
||||||
|
if not cfg:
|
||||||
|
return TelegramConfigRead(enabled=False, chat_id="", configured=False)
|
||||||
|
return TelegramConfigRead(enabled=cfg.enabled, chat_id=cfg.chat_id, configured=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("", response_model=TelegramConfigRead)
|
||||||
|
async def upsert_telegram_config(
|
||||||
|
req: TelegramConfigUpsert,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
cfg: Optional[TelegramConfig] = db.query(TelegramConfig).first()
|
||||||
|
if cfg:
|
||||||
|
if req.bot_token:
|
||||||
|
cfg.bot_token_enc = encrypt_secret(req.bot_token)
|
||||||
|
cfg.chat_id = req.chat_id
|
||||||
|
cfg.enabled = req.enabled
|
||||||
|
else:
|
||||||
|
if not req.bot_token:
|
||||||
|
raise HTTPException(422, "bot_token is required for initial setup")
|
||||||
|
cfg = TelegramConfig(
|
||||||
|
bot_token_enc=encrypt_secret(req.bot_token),
|
||||||
|
chat_id=req.chat_id,
|
||||||
|
enabled=req.enabled,
|
||||||
|
)
|
||||||
|
db.add(cfg)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(cfg)
|
||||||
|
return TelegramConfigRead(enabled=cfg.enabled, chat_id=cfg.chat_id, configured=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test")
|
||||||
|
async def test_telegram(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
cfg: Optional[TelegramConfig] = db.query(TelegramConfig).first()
|
||||||
|
if not cfg:
|
||||||
|
raise HTTPException(422, "Telegram is not configured yet")
|
||||||
|
ok, err = send_test_message(decrypt_secret(cfg.bot_token_enc), cfg.chat_id)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(502, err or "Telegram API error")
|
||||||
|
return {"success": True}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
from .avconnect import AVConnectAPI
|
|
||||||
from .gates import GatesService, OpenResult, call_open_gate
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"AVConnectAPI",
|
|
||||||
"GatesService",
|
|
||||||
"OpenResult",
|
|
||||||
"call_open_gate",
|
|
||||||
]
|
|
||||||
@@ -1,19 +1,26 @@
|
|||||||
|
import logging
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from fake_useragent import UserAgent
|
from fake_useragent import UserAgent
|
||||||
from models import Credential
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class AVConnectAPI:
|
class AVConnectAPI:
|
||||||
_BASE_URL = "https://www.avconnect.it"
|
_BASE_URL = "https://www.avconnect.it"
|
||||||
|
_LOGIN_SUCCESS_PATH = "/entraconf.php"
|
||||||
|
_LOGIN_DENIED_PATH = "/accessdenied.htm"
|
||||||
|
_SESSION_EXPIRED_PATH = "/accessespired.htm"
|
||||||
|
|
||||||
def __init__(self, credentials: Credential):
|
def __init__(self, username: str, password: str, session_id: str | None = None):
|
||||||
self._ua = UserAgent(browsers=["Chrome Mobile"], os=["Android"], platforms=["mobile"]).random
|
self._ua = UserAgent(browsers=["Chrome Mobile"], os=["Android"], platforms=["mobile"]).random
|
||||||
self._credentials = credentials
|
self._username = username
|
||||||
|
self._password = password
|
||||||
self._session = requests.Session()
|
self._session = requests.Session()
|
||||||
self._authenticated = False
|
|
||||||
|
|
||||||
if credentials.sessionid:
|
if session_id:
|
||||||
self._session.cookies.set("PHPSESSID", credentials.sessionid)
|
self._session.cookies.set("PHPSESSID", session_id)
|
||||||
self._authenticated = True
|
|
||||||
|
|
||||||
def _authenticate(self) -> bool:
|
def _authenticate(self) -> bool:
|
||||||
login_url = f"{self._BASE_URL}/loginone.php"
|
login_url = f"{self._BASE_URL}/loginone.php"
|
||||||
@@ -21,33 +28,62 @@ class AVConnectAPI:
|
|||||||
"User-Agent": self._ua,
|
"User-Agent": self._ua,
|
||||||
"Content-Type": "application/x-www-form-urlencoded"
|
"Content-Type": "application/x-www-form-urlencoded"
|
||||||
}
|
}
|
||||||
payload = f"userid={self._credentials.username}&password={self._credentials.password}&entra=Login"
|
payload = urllib.parse.urlencode({"userid": self._username, "password": self._password, "entra": "Login"})
|
||||||
response = self._session.post(login_url, data=payload, headers=headers)
|
# allow_redirects=False so we can inspect the Location header directly.
|
||||||
if response.ok and "PHPSESSID" in self._session.cookies:
|
response = self._session.post(login_url, data=payload, headers=headers, allow_redirects=False)
|
||||||
self._authenticated = True
|
location = response.headers.get("Location", "")
|
||||||
print("Authenticated")
|
if response.status_code == 302 and self._LOGIN_SUCCESS_PATH in location:
|
||||||
|
# Follow the redirect to complete the login and receive the PHPSESSID cookie.
|
||||||
|
redirect_url = location if location.startswith("http") else f"{self._BASE_URL}{location}"
|
||||||
|
self._session.get(redirect_url, headers={"User-Agent": self._ua})
|
||||||
|
logger.debug(f"AVConnect authentication successful: session_id={self._session.cookies.get('PHPSESSID')}")
|
||||||
return True
|
return True
|
||||||
|
if self._LOGIN_DENIED_PATH in location:
|
||||||
|
logger.warning("AVConnect authentication denied (invalid credentials)")
|
||||||
|
else:
|
||||||
|
logger.warning(f"AVConnect authentication failed: status={response.status_code} location={location}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _check_sessionid(self) -> bool:
|
def _check_sessionid(self) -> bool:
|
||||||
if not self._authenticated or not self._credentials.sessionid:
|
if not self._session.cookies.get("PHPSESSID"):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
exec_url = f"{self._BASE_URL}/exemacrocom.php"
|
exec_url = f"{self._BASE_URL}/exemacrocom.php"
|
||||||
headers = {
|
headers = {
|
||||||
"User-Agent": self._ua,
|
"User-Agent": self._ua,
|
||||||
}
|
}
|
||||||
response = self._session.get(exec_url, headers=headers)
|
response = self._session.get(exec_url, headers=headers, allow_redirects=False)
|
||||||
print(response.ok)
|
if response.status_code == 302 and self._SESSION_EXPIRED_PATH in response.headers.get("Location", ""):
|
||||||
|
logger.debug("AVConnect session expired")
|
||||||
|
return False
|
||||||
|
logger.debug(f"AVConnect session check: {response.ok}")
|
||||||
return response.ok
|
return response.ok
|
||||||
|
|
||||||
def exec_gate_macro(self, id_macro) -> bool:
|
def exec_gate_macro(self, id_macro) -> tuple[bool, str | None]:
|
||||||
if (not self._authenticated or not self._check_sessionid()) and not self._authenticate():
|
if not self._check_sessionid() and not self._authenticate():
|
||||||
raise Exception("Authentication failed.")
|
raise Exception("AVConnect authentication denied (invalid credentials)")
|
||||||
exec_url = f"{self._BASE_URL}/exemacrocom.php"
|
exec_url = f"{self._BASE_URL}/exemacrocom.php"
|
||||||
headers = {
|
headers = {
|
||||||
"User-Agent": self._ua,
|
"User-Agent": self._ua,
|
||||||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"
|
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"
|
||||||
}
|
}
|
||||||
payload = f"idmacrocom={id_macro}&nome=16"
|
payload = urllib.parse.urlencode({"idmacrocom": id_macro, "nome": "16"})
|
||||||
|
logger.debug(f"AVConnect executing gate macro: id_macro={id_macro} session_id={self._session.cookies.get('PHPSESSID')}")
|
||||||
response = self._session.post(exec_url, data=payload, headers=headers)
|
response = self._session.post(exec_url, data=payload, headers=headers)
|
||||||
return response.ok
|
if response.ok:
|
||||||
|
return True, self._session.cookies.get("PHPSESSID")
|
||||||
|
logger.warning(f"AVConnect gate macro execution failed: id_macro={id_macro} status={response.status_code} response={response.text}")
|
||||||
|
return False, self._session.cookies.get("PHPSESSID")
|
||||||
|
|
||||||
|
def validate_credentials(self) -> tuple[bool, str | None]:
|
||||||
|
"""Attempt a login and return (ok, session_id_or_None).
|
||||||
|
|
||||||
|
Returns (False, None) if the credentials are rejected.
|
||||||
|
Raises on unexpected network errors.
|
||||||
|
"""
|
||||||
|
logger.debug("AVConnect validating credentials by attempting login")
|
||||||
|
if self._authenticate():
|
||||||
|
logger.debug(f"AVConnect credentials valid, session_id={self._session.cookies.get('PHPSESSID')}")
|
||||||
|
return True, self._session.cookies.get("PHPSESSID")
|
||||||
|
logger.debug("AVConnect credentials invalid")
|
||||||
|
return False, None
|
||||||
|
|||||||
@@ -1,38 +1,39 @@
|
|||||||
from dataclasses import dataclass
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from models import Credential, Status, Gate
|
|
||||||
from .avconnect import AVConnectAPI
|
from .avconnect import AVConnectAPI
|
||||||
|
from .shelly import ShellyCloudAPI
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
def call_open_gate(
|
||||||
class OpenResult:
|
api_provider: str,
|
||||||
success: bool
|
avconnect_macro_id: Optional[str] = None,
|
||||||
error: Optional[str] = None
|
avconnect_username: Optional[str] = None,
|
||||||
new_session_id: Optional[str] = None
|
avconnect_password: Optional[str] = None,
|
||||||
|
avconnect_session_id: Optional[str] = None,
|
||||||
|
shelly_device_id: Optional[str] = None,
|
||||||
class GatesService:
|
shelly_server_uri: Optional[str] = None,
|
||||||
def open_gate(self, gate: Gate, credentials: Credential) -> OpenResult:
|
shelly_auth_key: Optional[str] = None,
|
||||||
if gate.status == Status.DISABLED:
|
mock: bool = False,
|
||||||
return OpenResult(success=False, error="Gate is disabled")
|
) -> tuple[bool, Optional[str], Optional[str]]:
|
||||||
try:
|
"""Attempt to open a gate. Returns (success, error_msg, new_avconnect_session_id)."""
|
||||||
api = AVConnectAPI(credentials)
|
if mock:
|
||||||
ok = api.exec_gate_macro(gate.id)
|
|
||||||
new_sid = api._session.cookies.get("PHPSESSID")
|
|
||||||
if not ok:
|
|
||||||
return OpenResult(success=False, error="Gate did not confirm open", new_session_id=new_sid)
|
|
||||||
return OpenResult(success=True, new_session_id=new_sid)
|
|
||||||
except Exception as e:
|
|
||||||
return OpenResult(success=False, error=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
def call_open_gate(gate: Gate, credentials: Credential) -> tuple[bool, Optional[str], Optional[str]]:
|
|
||||||
"""Attempt to open a gate. Returns (success, error_msg, new_session_id).
|
|
||||||
Respects the MOCK_AVCONNECT environment variable.
|
|
||||||
"""
|
|
||||||
from core.config import MOCK_AVCONNECT
|
|
||||||
if MOCK_AVCONNECT:
|
|
||||||
return True, None, None
|
return True, None, None
|
||||||
result = GatesService().open_gate(gate, credentials)
|
|
||||||
return result.success, result.error, result.new_session_id
|
if api_provider == "shelly":
|
||||||
|
try:
|
||||||
|
assert shelly_server_uri and shelly_auth_key and shelly_device_id
|
||||||
|
ShellyCloudAPI(shelly_server_uri, shelly_auth_key).open_gate(shelly_device_id)
|
||||||
|
return True, None, None
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e), None
|
||||||
|
|
||||||
|
# Default: AVConnect
|
||||||
|
try:
|
||||||
|
assert avconnect_username and avconnect_password and avconnect_macro_id
|
||||||
|
api = AVConnectAPI(avconnect_username, avconnect_password, avconnect_session_id)
|
||||||
|
ok, new_sid = api.exec_gate_macro(avconnect_macro_id)
|
||||||
|
if not ok:
|
||||||
|
return False, "Gate did not confirm open", new_sid
|
||||||
|
return True, None, new_sid
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e), None
|
||||||
|
|||||||
59
src/services/shelly.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ShellyCloudAPI:
|
||||||
|
"""Shelly Cloud Control API v2 client.
|
||||||
|
|
||||||
|
*server_uri* — base URL of your Shelly Cloud server
|
||||||
|
(e.g. ``https://shelly-3.eu.shelly.cloud``).
|
||||||
|
*auth_key* — long-lived API key generated in the Shelly Cloud portal.
|
||||||
|
|
||||||
|
Reference: https://shelly-api-docs.shelly.cloud/cloud-control-api/communication-v2
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, server_uri: str, auth_key: str):
|
||||||
|
self._server_uri = server_uri.rstrip("/")
|
||||||
|
self._auth_key = auth_key
|
||||||
|
|
||||||
|
def open_gate(self, device_id: str, channel: int = 0) -> None:
|
||||||
|
"""Send a switch-on command to the device via the v2 API.
|
||||||
|
|
||||||
|
Raises on HTTP errors or API-level errors.
|
||||||
|
"""
|
||||||
|
url = f"{self._server_uri}/v2/devices/api/set/switch"
|
||||||
|
params = {"auth_key": self._auth_key}
|
||||||
|
payload = {"id": device_id, "channel": channel, "on": True}
|
||||||
|
logger.debug("Shelly v2 open_gate: device_id=%s channel=%d", device_id, channel)
|
||||||
|
response = requests.post(url, params=params, json=payload, timeout=10)
|
||||||
|
if not response.ok:
|
||||||
|
# v2 error body: {"error": "...", "data": {"messages": [...]}}
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
error_str = body.get("error", response.text)
|
||||||
|
messages = body.get("data", {}).get("messages", [])
|
||||||
|
detail = f"{error_str}: {'; '.join(messages)}" if messages else error_str
|
||||||
|
except Exception:
|
||||||
|
detail = response.text
|
||||||
|
raise Exception(f"Shelly Cloud API error ({response.status_code}): {detail}")
|
||||||
|
|
||||||
|
def validate_credentials(self) -> bool:
|
||||||
|
"""Validate the auth key by issuing a v2 get-state probe.
|
||||||
|
|
||||||
|
Any response other than 401 (Unauthorized) is treated as valid auth.
|
||||||
|
Raises on unexpected network errors.
|
||||||
|
"""
|
||||||
|
url = f"{self._server_uri}/v2/devices/api/get"
|
||||||
|
params = {"auth_key": self._auth_key}
|
||||||
|
# Send a single dummy id; the server will return an empty/not-found result
|
||||||
|
# but will authenticate the key first. A 401 means the key is invalid.
|
||||||
|
payload = {"ids": ["validate"]}
|
||||||
|
response = requests.post(url, params=params, json=payload, timeout=10)
|
||||||
|
if response.status_code == 401:
|
||||||
|
logger.warning("Shelly credentials validation failed: 401 Unauthorized")
|
||||||
|
return False
|
||||||
|
logger.debug("Shelly credentials valid (status=%d)", response.status_code)
|
||||||
|
return True
|
||||||
52
src/services/telegram.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_TELEGRAM_API = "https://api.telegram.org/bot{token}/sendMessage"
|
||||||
|
|
||||||
|
|
||||||
|
def send_gate_notification(
|
||||||
|
bot_token: str,
|
||||||
|
chat_id: str,
|
||||||
|
gate_name: str,
|
||||||
|
opened_by: str,
|
||||||
|
ip: str | None,
|
||||||
|
) -> None:
|
||||||
|
"""Send a Telegram message. Runs synchronously; call from a background thread or task."""
|
||||||
|
ts = datetime.now(timezone.utc).strftime("%d/%m/%Y %H:%M UTC")
|
||||||
|
text = (
|
||||||
|
f"🔓 *Gate opened*\n"
|
||||||
|
f"*Gate:* {gate_name}\n"
|
||||||
|
f"*By:* `{opened_by}`\n"
|
||||||
|
f"*Time:* {ts}"
|
||||||
|
)
|
||||||
|
if ip:
|
||||||
|
text += f"\n*IP:* `{ip}`"
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
_TELEGRAM_API.format(token=bot_token),
|
||||||
|
json={"chat_id": chat_id, "text": text, "parse_mode": "Markdown"},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if not resp.ok:
|
||||||
|
logger.warning("Telegram notification failed: %s", resp.text)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Telegram notification error: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def send_test_message(bot_token: str, chat_id: str) -> tuple[bool, str]:
|
||||||
|
"""Send a test message. Returns (success, error_or_empty)."""
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
_TELEGRAM_API.format(token=bot_token),
|
||||||
|
json={"chat_id": chat_id, "text": "✅ Lagomare Gates — Telegram notifications are working!"},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if resp.ok:
|
||||||
|
return True, ""
|
||||||
|
return False, resp.json().get("description", resp.text)
|
||||||
|
except Exception as exc:
|
||||||
|
return False, str(exc)
|
||||||
823
src/static/add-to-homescreen/add-to-homescreen.min.css
vendored
Normal file
@@ -0,0 +1,823 @@
|
|||||||
|
@import url(roboto.css);
|
||||||
|
.adhs-container {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: rgba(0, 0, 0, 0);
|
||||||
|
z-index: 999999;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease-in-out, background-color 0.3s ease-in-out;
|
||||||
|
font-family: "Roboto", sans-serif;
|
||||||
|
color: #212121;
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
-webkit-backdrop-filter: blur(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.visible {
|
||||||
|
background-color: rgba(0, 0, 0, 0.9);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container .adhs-modal {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border-radius: 7px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||||
|
left: 50%;
|
||||||
|
margin: auto;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 49px 19px 32px 19px;
|
||||||
|
position: absolute;
|
||||||
|
transition: opacity 0.3s ease-in-out;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: normal;
|
||||||
|
top: 40%;
|
||||||
|
transform: translateY(-50%) translateX(-50%);
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes adhs-bounce-modal {
|
||||||
|
0%,
|
||||||
|
20%,
|
||||||
|
50%,
|
||||||
|
80%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(-50%) translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
40% {
|
||||||
|
transform: translateY(-40%) translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
60% {
|
||||||
|
transform: translateY(-55%) translateX(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.visible .adhs-modal {
|
||||||
|
opacity: 1;
|
||||||
|
animation: adhs-bounce-modal 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container .adhs-logo {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin: auto;
|
||||||
|
height: 80px;
|
||||||
|
width: 80px;
|
||||||
|
top: -45px;
|
||||||
|
z-index: 1000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container .adhs-logo img {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container .adhs-modal .adhs-title {
|
||||||
|
font-size: 27px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0px 0px 18px 0px;
|
||||||
|
text-align: justify;
|
||||||
|
word-spacing: -0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container .adhs-modal .adhs-error-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 5px 10px 15px 10px;
|
||||||
|
text-align: justify;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container .adhs-modal .adhs-error-body {
|
||||||
|
font-size: 20px;
|
||||||
|
margin: 0px 10px 20px 10px;
|
||||||
|
text-align: justify;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container .adhs-modal .adhs-error-copy-link-button {
|
||||||
|
background-color: white;
|
||||||
|
border: 1px solid;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 19px;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container .adhs-modal .adhs-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container .adhs-modal .adhs-list .adhs-emphasis {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container .adhs-modal .adhs-list .adhs-your-app-icon {
|
||||||
|
height: 40px;
|
||||||
|
margin: 0px 5px -13px 5px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container .adhs-modal .adhs-list .adhs-list-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: start;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin: 0px 0px 10px 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container .adhs-modal .adhs-list .adhs-list-item .adhs-number-container {
|
||||||
|
flex-direction: column;
|
||||||
|
display: flex;
|
||||||
|
/* margin: 0px 5px 0px 0px; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-desktop
|
||||||
|
.adhs-modal
|
||||||
|
.adhs-list
|
||||||
|
.adhs-list-item
|
||||||
|
.adhs-number-container {
|
||||||
|
margin: 0px 5px 0px 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-mobile
|
||||||
|
.adhs-modal
|
||||||
|
.adhs-list
|
||||||
|
.adhs-list-item
|
||||||
|
.adhs-number-container {
|
||||||
|
margin: 5px 5px 0px 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container .adhs-modal .adhs-list .adhs-list-item .adhs-circle {
|
||||||
|
font-size: 30px;
|
||||||
|
height: 32px;
|
||||||
|
width: 32px;
|
||||||
|
border-radius: 100px;
|
||||||
|
background-color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container .adhs-modal .adhs-list .adhs-list-item .adhs-number {
|
||||||
|
padding: 6px 0px 0px 11px;
|
||||||
|
color: white;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"]
|
||||||
|
.adhs-container
|
||||||
|
.adhs-modal
|
||||||
|
.adhs-list
|
||||||
|
.adhs-list-item
|
||||||
|
.adhs-number {
|
||||||
|
padding: 6px 11px 0px 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container .adhs-modal .adhs-list .adhs-list-item .adhs-instruction {
|
||||||
|
font-size: 19px;
|
||||||
|
line-height: 27px;
|
||||||
|
padding: 0px 5px 0px 5px;
|
||||||
|
/* text-align: justify; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-mobile
|
||||||
|
.adhs-modal
|
||||||
|
.adhs-list
|
||||||
|
.adhs-list-item
|
||||||
|
.adhs-instruction:has(.adhs-list-button) {
|
||||||
|
padding-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container .adhs-modal .adhs-list .adhs-list-item .adhs-list-button {
|
||||||
|
display: inline-block;
|
||||||
|
/* border: 0.5px solid lightgrey; */
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 1px 1px 2px gray;
|
||||||
|
margin-right: 3px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container
|
||||||
|
.adhs-modal
|
||||||
|
.adhs-list
|
||||||
|
.adhs-list-item
|
||||||
|
.adhs-list-button:has(.adhs-list-button-image-only) {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container
|
||||||
|
.adhs-modal
|
||||||
|
.adhs-list
|
||||||
|
.adhs-list-item
|
||||||
|
.adhs-list-button
|
||||||
|
.adhs-list-button-text {
|
||||||
|
display: inline;
|
||||||
|
font-size: 16px;
|
||||||
|
position: relative;
|
||||||
|
top: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container
|
||||||
|
.adhs-modal
|
||||||
|
.adhs-list
|
||||||
|
.adhs-list-item
|
||||||
|
.adhs-list-button
|
||||||
|
.adhs-list-button-image-only {
|
||||||
|
display: inline;
|
||||||
|
position: relative;
|
||||||
|
left: 0px;
|
||||||
|
top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container
|
||||||
|
.adhs-modal
|
||||||
|
.adhs-list
|
||||||
|
.adhs-list-item
|
||||||
|
.adhs-list-button
|
||||||
|
.adhs-list-button-image-left {
|
||||||
|
display: inline;
|
||||||
|
position: relative;
|
||||||
|
margin: 0 5px 0 0;
|
||||||
|
top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container
|
||||||
|
.adhs-modal
|
||||||
|
.adhs-list
|
||||||
|
.adhs-list-item
|
||||||
|
.adhs-list-button
|
||||||
|
.adhs-list-button-image-right {
|
||||||
|
display: inline;
|
||||||
|
position: relative;
|
||||||
|
margin: 0 0 0 5px;
|
||||||
|
top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* IOS Safari */
|
||||||
|
.adhs-container.adhs-ios.adhs-safari
|
||||||
|
.adhs-modal
|
||||||
|
.adhs-list
|
||||||
|
.adhs-ios-safari-sharing-api-button {
|
||||||
|
height: 35px;
|
||||||
|
margin: 0px 5px -7px 5px;
|
||||||
|
box-shadow: 1px 1px 2px gray;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-ios.adhs-safari
|
||||||
|
.adhs-modal
|
||||||
|
.adhs-list
|
||||||
|
.adhs-ios-safari-add-to-home-screen-button {
|
||||||
|
height: 33px;
|
||||||
|
margin: 0px 5px -7px 5px;
|
||||||
|
box-shadow: 1px 1px 2px gray;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes adhs-ios-safari-bouncing-arrow-animation {
|
||||||
|
0%,
|
||||||
|
20%,
|
||||||
|
50%,
|
||||||
|
80%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
40% {
|
||||||
|
transform: translateY(-30px);
|
||||||
|
}
|
||||||
|
|
||||||
|
60% {
|
||||||
|
transform: translateY(-15px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes adhs-ios-ipad-safari-bouncing-arrow-animation {
|
||||||
|
0%,
|
||||||
|
20%,
|
||||||
|
50%,
|
||||||
|
80%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0) rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
40% {
|
||||||
|
transform: translateY(30px) rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
60% {
|
||||||
|
transform: translateY(15px) rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (orientation: landscape) {
|
||||||
|
.adhs-container .adhs-bouncing-arrow {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-ios.adhs-safari
|
||||||
|
.adhs-ios-safari-bouncing-arrow-container
|
||||||
|
img {
|
||||||
|
height: 60px;
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100dvh - 60px);
|
||||||
|
bottom: unset;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin: auto;
|
||||||
|
animation: adhs-ios-safari-bouncing-arrow-animation 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-ios.adhs-safari.adhs-ios26
|
||||||
|
.adhs-ios-safari-bouncing-arrow-container
|
||||||
|
img {
|
||||||
|
height: 60px;
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100dvh - 60px);
|
||||||
|
bottom: unset;
|
||||||
|
left: unset;
|
||||||
|
right: 7%;
|
||||||
|
margin: 0px 0px;
|
||||||
|
animation: adhs-ios-safari-bouncing-arrow-animation 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-ios.adhs-safari
|
||||||
|
.adhs-ios-ipad-safari-bouncing-arrow-container
|
||||||
|
img {
|
||||||
|
height: 60px;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: unset;
|
||||||
|
left: unset;
|
||||||
|
right: 11%;
|
||||||
|
margin: 0px 0px;
|
||||||
|
animation: adhs-ios-ipad-safari-bouncing-arrow-animation 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-ios.adhs-safari.adhs-ios26
|
||||||
|
.adhs-ios-ipad-safari-bouncing-arrow-container
|
||||||
|
img {
|
||||||
|
height: 60px;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: unset;
|
||||||
|
left: unset;
|
||||||
|
right: 9%;
|
||||||
|
margin: 0px 0px;
|
||||||
|
animation: adhs-ios-ipad-safari-bouncing-arrow-animation 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* IOS Chrome */
|
||||||
|
@keyframes adhs-ios-chrome-bouncing-arrow-animation {
|
||||||
|
0%,
|
||||||
|
20%,
|
||||||
|
50%,
|
||||||
|
80%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
40% {
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
60% {
|
||||||
|
transform: translateY(5px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-ios.adhs-chrome
|
||||||
|
.adhs-modal
|
||||||
|
.adhs-list
|
||||||
|
.adhs-ios-chrome-more-button {
|
||||||
|
height: 35px;
|
||||||
|
margin: 0px 5px -7px 5px;
|
||||||
|
box-shadow: 1px 1px 2px gray;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-ios.adhs-chrome
|
||||||
|
.adhs-ios-chrome-bouncing-arrow-container
|
||||||
|
img {
|
||||||
|
height: 40px;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 18px;
|
||||||
|
animation: adhs-ios-chrome-bouncing-arrow-animation 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-ios.adhs-chrome
|
||||||
|
.adhs-modal
|
||||||
|
.adhs-list
|
||||||
|
.adhs-ios-chrome-add-to-home-screen-button {
|
||||||
|
height: 35px;
|
||||||
|
margin: 0px 5px -7px 5px;
|
||||||
|
box-shadow: 1px 1px 2px gray;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Android Chrome */
|
||||||
|
@keyframes adhs-android-chrome-bouncing-arrow-animation {
|
||||||
|
0%,
|
||||||
|
20%,
|
||||||
|
50%,
|
||||||
|
80%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
40% {
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
60% {
|
||||||
|
transform: translateY(5px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-android.adhs-chrome
|
||||||
|
.adhs-android-chrome-bouncing-arrow-container
|
||||||
|
img {
|
||||||
|
height: 40px;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 15px;
|
||||||
|
animation: adhs-android-chrome-bouncing-arrow-animation 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes adhs-android-edge-portrait-bouncing-arrow-animation {
|
||||||
|
0%,
|
||||||
|
20%,
|
||||||
|
50%,
|
||||||
|
80%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
40% {
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
60% {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes adhs-android-edge-landscape-bouncing-arrow-animation {
|
||||||
|
0%,
|
||||||
|
20%,
|
||||||
|
50%,
|
||||||
|
80%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
40% {
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
60% {
|
||||||
|
transform: translateY(5px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-android.adhs-edge
|
||||||
|
.adhs-android-edge-bouncing-arrow-container
|
||||||
|
img {
|
||||||
|
position: absolute;
|
||||||
|
height: 40px;
|
||||||
|
top: calc(100dvh - 60px);
|
||||||
|
right: 25px;
|
||||||
|
animation: adhs-android-edge-portrait-bouncing-arrow-animation 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (orientation: landscape) {
|
||||||
|
.adhs-container.adhs-android.adhs-edge
|
||||||
|
.adhs-android-edge-bouncing-arrow-container
|
||||||
|
img {
|
||||||
|
position: absolute;
|
||||||
|
height: 40px;
|
||||||
|
top: 20px;
|
||||||
|
right: 33px;
|
||||||
|
animation: adhs-android-edge-landscape-bouncing-arrow-animation 1.5s infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-android.adhs-chrome
|
||||||
|
.adhs-modal
|
||||||
|
.adhs-list
|
||||||
|
.adhs-android-chrome-more-button {
|
||||||
|
height: 30px;
|
||||||
|
background: white;
|
||||||
|
margin: 0px 5px -7px 5px;
|
||||||
|
box-shadow: 1px 1px 2px gray;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-android.adhs-chrome
|
||||||
|
.adhs-modal
|
||||||
|
.adhs-list
|
||||||
|
.adhs-android-chrome-add-to-homescreen-button {
|
||||||
|
height: 30px;
|
||||||
|
background: white;
|
||||||
|
margin: 0px 5px -9px 5px;
|
||||||
|
box-shadow: 1px 1px 2px gray;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0px 7px 0px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-android.adhs-chrome
|
||||||
|
.adhs-modal
|
||||||
|
.adhs-list
|
||||||
|
.adhs-android-chrome-install-app {
|
||||||
|
height: 30px;
|
||||||
|
background: white;
|
||||||
|
margin: 10px 5px -10px 5px;
|
||||||
|
box-shadow: 1px 1px 2px gray;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-android.adhs-chrome
|
||||||
|
.adhs-modal
|
||||||
|
.adhs-list
|
||||||
|
.adhs-list-item
|
||||||
|
.adhs-instruction {
|
||||||
|
line-height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* In-App System Browser */
|
||||||
|
.adhs-container.adhs-inappbrowser-openinsystembrowser .adhs-modal .adhs-title {
|
||||||
|
padding: 0px 0px 9px 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-inappbrowser-openinsystembrowser
|
||||||
|
.adhs-modal
|
||||||
|
.adhs-list
|
||||||
|
.adhs-emphasis {
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 0px 0px 0px 5px;
|
||||||
|
box-shadow: 1px 1px 2px gray;
|
||||||
|
background: white;
|
||||||
|
width: fit-content;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-inappbrowser-openinsystembrowser
|
||||||
|
.adhs-modal
|
||||||
|
.adhs-list
|
||||||
|
.adhs-list-item {
|
||||||
|
margin: 6px 0px 9px 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-inappbrowser-openinsystembrowser
|
||||||
|
.adhs-modal
|
||||||
|
.adhs-list
|
||||||
|
.adhs-list-item
|
||||||
|
.adhs-instruction {
|
||||||
|
padding: 0px 0px 2px 9px;
|
||||||
|
line-height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes adhs-inappbrowser-openinsystembrowser-bouncing-arrow-animation {
|
||||||
|
0%,
|
||||||
|
20%,
|
||||||
|
50%,
|
||||||
|
80%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
40% {
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
60% {
|
||||||
|
transform: translateY(5px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-inappbrowser-openinsystembrowser
|
||||||
|
.adhs-inappbrowser-openinsystembrowser-bouncing-arrow-container
|
||||||
|
img {
|
||||||
|
height: 40px;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 6px;
|
||||||
|
animation: adhs-inappbrowser-openinsystembrowser-bouncing-arrow-animation 1.5s
|
||||||
|
infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-inappbrowser-openinsystembrowser
|
||||||
|
.adhs-modal
|
||||||
|
.adhs-list
|
||||||
|
.adhs-more-button {
|
||||||
|
height: 35px;
|
||||||
|
background: white;
|
||||||
|
margin: 0px 5px -11px 5px;
|
||||||
|
box-shadow: 1px 1px 2px gray;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* In-App Safari Browser */
|
||||||
|
.adhs-container.adhs-inappbrowser-openinsafari .adhs-modal .adhs-title {
|
||||||
|
padding: 0px 0px 9px 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-inappbrowser-openinsafari
|
||||||
|
.adhs-modal
|
||||||
|
.adhs-list
|
||||||
|
.adhs-emphasis {
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 0px 0px 0px 5px;
|
||||||
|
box-shadow: 1px 1px 2px gray;
|
||||||
|
background: white;
|
||||||
|
width: fit-content;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-inappbrowser-openinsafari
|
||||||
|
.adhs-modal
|
||||||
|
.adhs-list
|
||||||
|
.adhs-list-item {
|
||||||
|
margin: 6px 0px 9px 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-inappbrowser-openinsafari
|
||||||
|
.adhs-modal
|
||||||
|
.adhs-list
|
||||||
|
.adhs-list-item
|
||||||
|
.adhs-instruction {
|
||||||
|
padding: 0px 0px 0px 9px;
|
||||||
|
line-height: 34px;
|
||||||
|
margin: 4px 0px 0px 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes adhs-inappbrowser-openinsafari-bouncing-arrow-animation {
|
||||||
|
0%,
|
||||||
|
20%,
|
||||||
|
50%,
|
||||||
|
80%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
40% {
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
60% {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-inappbrowser-openinsafari
|
||||||
|
.adhs-inappbrowser-openinsafari-bouncing-arrow-container
|
||||||
|
img {
|
||||||
|
height: 40px;
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100dvh - 40px);
|
||||||
|
right: 15px;
|
||||||
|
animation: adhs-inappbrowser-openinsafari-bouncing-arrow-animation 1.5s
|
||||||
|
infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-inappbrowser-openinsafari
|
||||||
|
.adhs-modal
|
||||||
|
.adhs-list
|
||||||
|
.adhs-more-button {
|
||||||
|
height: 35px;
|
||||||
|
background: white;
|
||||||
|
margin: 0px 5px -11px 5px;
|
||||||
|
box-shadow: 1px 1px 2px gray;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-desktop .adhs-modal {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border-radius: 7px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||||
|
padding: 45px 45px 28px 40px;
|
||||||
|
margin: auto;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container .adhs-modal .adhs-install-app {
|
||||||
|
/* font-size: 22px; */
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
/* margin: 0px 0px 0px 0px; */
|
||||||
|
padding: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-desktop .adhs-modal .adhs-install-app {
|
||||||
|
font-size: 22px;
|
||||||
|
margin: 0px 0px 0px 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-mobile .adhs-modal .adhs-install-app {
|
||||||
|
font-size: 23px;
|
||||||
|
margin: -2px 0px 0px 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container .adhs-modal .adhs-app-name {
|
||||||
|
/* font-size: 22px; */
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 5px auto 1px auto;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-desktop .adhs-modal .adhs-app-name {
|
||||||
|
font-size: 22px;
|
||||||
|
margin: 0px auto 1px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-mobile .adhs-modal .adhs-app-name {
|
||||||
|
font-size: 23px;
|
||||||
|
margin: 0px auto 18px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container .adhs-modal .adhs-app-url {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 300;
|
||||||
|
margin: 0 auto 24px auto;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-desktop-chrome .adhs-modal .adhs-app-url {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-desktop-safari .adhs-modal .adhs-app-url {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-mobile .adhs-modal .adhs-app-url {
|
||||||
|
margin: 0 auto 20px auto;
|
||||||
|
}
|
||||||
|
.adhs-container .adhs-modal .adhs-blurb {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 400;
|
||||||
|
margin: 0px 0px 14px 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-mobile .adhs-modal .adhs-blurb {
|
||||||
|
font-size: 15px;
|
||||||
|
margin: 13px 0px 5px 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-desktop-chrome .adhs-modal .adhs-blurb {
|
||||||
|
margin-top: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-desktop-safari .adhs-modal .adhs-blurb {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container .adhs-modal .adhs-button-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-around;
|
||||||
|
margin-top: 11px;
|
||||||
|
}
|
||||||
|
.adhs-container .adhs-modal .adhs-button {
|
||||||
|
background-color: #1d64f0;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 400;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 33px;
|
||||||
|
border-radius: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adhs-container.adhs-desktop-safari
|
||||||
|
.adhs-desktop-safari-bouncing-arrow-container
|
||||||
|
img {
|
||||||
|
height: 40px;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 88px;
|
||||||
|
animation: adhs-ios-chrome-bouncing-arrow-animation 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
1
src/static/add-to-homescreen/add-to-homescreen.min.js
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="17px" height="22px" viewBox="0 0 17 22" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<!-- Generator: Sketch 52.5 (67469) - http://www.bohemiancoding.com/sketch -->
|
||||||
|
<title>add_to_home_screen</title>
|
||||||
|
<desc>Created with Sketch.</desc>
|
||||||
|
<g id="Icons" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<g id="Rounded" transform="translate(-783.000000, -1705.000000)">
|
||||||
|
<g id="Device" transform="translate(100.000000, 1650.000000)">
|
||||||
|
<g id="-Round-/-Device-/-add_to_home_screen" transform="translate(680.000000, 54.000000)">
|
||||||
|
<g transform="translate(0.000000, 0.000000)">
|
||||||
|
<polygon id="Path" points="0 0 24 0 24 24 0 24"></polygon>
|
||||||
|
<path d="M18,1.01 L8,1 C6.9,1 6,1.9 6,3 L6,6 C6,6.55 6.45,7 7,7 C7.55,7 8,6.55 8,6 L8,5 L18,5 L18,19 L8,19 L8,18 C8,17.45 7.55,17 7,17 C6.45,17 6,17.45 6,18 L6,21 C6,22.1 6.9,23 8,23 L18,23 C19.1,23 20,22.1 20,21 L20,3 C20,1.9 19.1,1.01 18,1.01 Z M11,15 C11.55,15 12,14.55 12,14 L12,9 C12,8.45 11.55,8 11,8 L6,8 C5.45,8 5,8.45 5,9 C5,9.55 5.45,10 6,10 L8.59,10 L3.7,14.89 C3.31,15.28 3.31,15.91 3.7,16.3 C4.09,16.69 4.72,16.69 5.11,16.3 L10,11.41 L10,14 C10,14.55 10.45,15 11,15 Z" id="🔹Icon-Color" fill="#1D1D1D"></path>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="82" height="115" viewBox="0 0 82 115" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M45.4071 2.12778C43.2691 -0.0300287 39.7868 -0.0461506 37.629 2.09177L2.46551 36.9311C0.307703 39.0691 0.291581 42.5514 2.4295 44.7092C4.56741 46.867 8.04978 46.8832 10.2076 44.7452L41.464 13.7769L72.4323 45.0333C74.5702 47.1911 78.0526 47.2072 80.2104 45.0693C82.3682 42.9314 82.3843 39.449 80.2464 37.2912L45.4071 2.12778ZM46.4999 114.025L46.9999 6.02428L36.0001 5.97335L35.5001 113.975L46.4999 114.025Z" fill="#3478F6"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 538 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="11" cy="19" r="2" fill="black"/>
|
||||||
|
<circle cx="11" cy="3" r="2" fill="black"/>
|
||||||
|
<circle cx="11" cy="11" r="2" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 237 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M17.5 12a5.5 5.5 0 1 1 0 11a5.5 5.5 0 0 1 0-11M13.75 2A2.25 2.25 0 0 1 16 4.25v6.924a6.5 6.5 0 0 0-1.5.558V4.25a.75.75 0 0 0-.75-.75h-7.5a.75.75 0 0 0-.75.75v15.5c0 .414.336.75.75.75l5.483.001a6.5 6.5 0 0 0 1.077 1.5L6.25 22A2.25 2.25 0 0 1 4 19.75V4.25A2.25 2.25 0 0 1 6.25 2zm3.75 12l-.09.007a.5.5 0 0 0-.402.402L17 14.5V17h-2.502l-.09.008a.5.5 0 0 0-.402.402l-.008.09l.008.09a.5.5 0 0 0 .402.402l.09.008H17v2.503l.008.09a.5.5 0 0 0 .402.402l.09.008l.09-.008a.5.5 0 0 0 .402-.402l.008-.09V18l2.504.001l.09-.008a.5.5 0 0 0 .402-.402l.008-.09l-.008-.09a.5.5 0 0 0-.403-.402l-.09-.008H18v-2.5l-.008-.09a.5.5 0 0 0-.402-.403zm-8.751 3.504L11 17.499c0 .517.06 1.02.174 1.5l-2.423.005a.75.75 0 0 1-.002-1.5"/></svg>
|
||||||
|
After Width: | Height: | Size: 823 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M3 6h18v2H3zm0 5h18v2H3zm0 5h18v2H3z"/></svg>
|
||||||
|
After Width: | Height: | Size: 157 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="82" height="115" viewBox="0 0 82 115" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M45.4071 2.12778C43.2691 -0.0300287 39.7868 -0.0461506 37.629 2.09177L2.46551 36.9311C0.307703 39.0691 0.291581 42.5514 2.4295 44.7092C4.56741 46.867 8.04978 46.8832 10.2076 44.7452L41.464 13.7769L72.4323 45.0333C74.5702 47.1911 78.0526 47.2072 80.2104 45.0693C82.3682 42.9314 82.3843 39.449 80.2464 37.2912L45.4071 2.12778ZM46.4999 114.025L46.9999 6.02428L36.0001 5.97335L35.5001 113.975L46.4999 114.025Z" fill="#3478F6"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 538 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="24" height="18" viewBox="0 0 24 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="0.5" y="0.5" width="22.33" height="16.4975" rx="1.5" stroke="black"/>
|
||||||
|
<line x1="1.16626" y1="4.16602" x2="22.1633" y2="4.16602" stroke="black"/>
|
||||||
|
<rect x="3.49951" y="11.665" width="16.331" height="3.4995" rx="1" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 339 B |
@@ -0,0 +1,10 @@
|
|||||||
|
<svg width="17" height="22" viewBox="0 0 17 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_377_20)">
|
||||||
|
<path d="M2.862 21.708H14.094C15.066 21.708 15.768 21.492 16.254 21.006C16.74 20.52 16.956 19.818 16.956 18.9V8.856C16.956 7.938 16.74 7.236 16.254 6.75C15.768 6.264 15.066 6.048 14.094 6.048H11.34V7.236H14.094C14.634 7.236 15.066 7.398 15.336 7.668C15.606 7.938 15.768 8.37 15.768 8.964V18.792C15.768 19.332 15.606 19.764 15.336 20.088C15.066 20.412 14.634 20.52 14.094 20.52H2.916C2.376 20.52 1.944 20.358 1.674 20.088C1.404 19.818 1.242 19.386 1.242 18.792V8.91C1.242 8.37 1.404 7.938 1.674 7.614C1.944 7.29 2.376 7.182 2.916 7.182H5.67V5.994H2.862C1.89 5.994 1.188 6.21 0.702 6.696C0.216 7.182 0 7.938 0 8.856V18.846C0 19.764 0.216 20.52 0.702 20.952C1.188 21.384 1.944 21.708 2.862 21.708ZM8.478 14.202C8.64 14.202 8.802 14.148 8.91 14.04C9.018 13.932 9.072 13.77 9.072 13.608V3.078L9.018 1.674L9.882 2.592L11.556 4.32C11.664 4.428 11.826 4.482 11.988 4.482C12.15 4.482 12.258 4.428 12.366 4.32C12.474 4.212 12.528 4.104 12.528 3.942C12.528 3.78 12.474 3.672 12.366 3.564L8.91 0.216C8.856 0.162 8.748 0.108 8.694 0.054C8.64 0 8.586 0 8.478 0C8.424 0 8.316 0 8.262 0.054C8.208 0.108 8.1 0.162 8.046 0.216L4.644 3.564C4.482 3.672 4.428 3.78 4.428 3.942C4.428 4.104 4.482 4.212 4.59 4.32C4.698 4.428 4.806 4.482 4.968 4.482C5.13 4.482 5.292 4.428 5.4 4.32L7.074 2.592L7.938 1.674L7.884 3.078V13.608C7.884 13.77 7.938 13.878 8.046 14.04C8.154 14.202 8.316 14.202 8.478 14.202Z" fill="black"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_377_20">
|
||||||
|
<rect width="17" height="22" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 -960 960 960" width="48"><path d="M207.858-432Q188-432 174-446.142q-14-14.141-14-34Q160-500 174.142-514q14.141-14 34-14Q228-528 242-513.858q14 14.141 14 34Q256-460 241.858-446q-14.141 14-34 14Zm272 0Q460-432 446-446.142q-14-14.141-14-34Q432-500 446.142-514q14.141-14 34-14Q500-528 514-513.858q14 14.141 14 34Q528-460 513.858-446q-14.141 14-34 14Zm272 0Q732-432 718-446.142q-14-14.141-14-34Q704-500 718.142-514q14.141-14 34-14Q772-528 786-513.858q14 14.141 14 34Q800-460 785.858-446q-14.141 14-34 14Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 571 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="82" height="114" viewBox="0 0 82 114" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M37.3609 111.891C39.5088 114.039 42.9912 114.039 45.1391 111.891L80.1409 76.8891C82.2888 74.7412 82.2888 71.2588 80.1409 69.1109C77.993 66.963 74.5106 66.963 72.3627 69.1109L41.25 100.224L10.1373 69.1109C7.98942 66.963 4.50701 66.963 2.35913 69.1109C0.211243 71.2588 0.211243 74.7412 2.35913 76.8891L37.3609 111.891ZM35.75 -0.000549316L35.75 108.002H46.75L46.75 -0.000549316L35.75 -0.000549316Z" fill="#3478F6"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 527 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="82" height="115" viewBox="0 0 82 115" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M45.4071 2.12778C43.2691 -0.0300287 39.7868 -0.0461506 37.629 2.09177L2.46551 36.9311C0.307703 39.0691 0.291581 42.5514 2.4295 44.7092C4.56741 46.867 8.04978 46.8832 10.2076 44.7452L41.464 13.7769L72.4323 45.0333C74.5702 47.1911 78.0526 47.2072 80.2104 45.0693C82.3682 42.9314 82.3843 39.449 80.2464 37.2912L45.4071 2.12778ZM46.4999 114.025L46.9999 6.02428L36.0001 5.97335L35.5001 113.975L46.4999 114.025Z" fill="#3478F6"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 538 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="82" height="115" viewBox="0 0 82 115" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M45.4071 2.12778C43.2691 -0.0300287 39.7868 -0.0461506 37.629 2.09177L2.46551 36.9311C0.307703 39.0691 0.291581 42.5514 2.4295 44.7092C4.56741 46.867 8.04978 46.8832 10.2076 44.7452L41.464 13.7769L72.4323 45.0333C74.5702 47.1911 78.0526 47.2072 80.2104 45.0693C82.3682 42.9314 82.3843 39.449 80.2464 37.2912L45.4071 2.12778ZM46.4999 114.025L46.9999 6.02428L36.0001 5.97335L35.5001 113.975L46.4999 114.025Z" fill="#3478F6"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 538 B |
@@ -0,0 +1,10 @@
|
|||||||
|
<svg width="16" height="22" viewBox="0 0 16 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_539_19)">
|
||||||
|
<path d="M1.5 22C1.1 22 0.75 21.85 0.45 21.55C0.15 21.25 0 20.9 0 20.5V7.775C0 7.375 0.15 7.025 0.45 6.725C0.75 6.425 1.1 6.275 1.5 6.275H5.725V7.775H1.5V20.5H14.5V7.775H10.225V6.275H14.5C14.9 6.275 15.25 6.425 15.55 6.725C15.85 7.025 16 7.375 16 7.775V20.5C16 20.9 15.85 21.25 15.55 21.55C15.25 21.85 14.9 22 14.5 22H1.5ZM7.225 14.325V2.9L5.025 5.1L3.95 4.025L7.975 0L12 4.025L10.925 5.1L8.725 2.9V14.325H7.225Z" fill="#3A3838"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_539_19">
|
||||||
|
<rect width="16" height="22" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 674 B |
@@ -0,0 +1,12 @@
|
|||||||
|
<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_538_4)">
|
||||||
|
<path d="M16.984 13.2512C16.983 13.7444 16.885 14.2325 16.696 14.6878C16.506 15.1431 16.229 15.5566 15.88 15.9047C15.532 16.2528 15.118 16.5287 14.662 16.7166C14.207 16.9046 13.719 17.0009 13.226 17L3.74401 16.9834C2.74901 16.9817 1.796 16.5844 1.094 15.8789C0.392002 15.1734 -0.00199242 14.2175 7.57729e-06 13.2216L0.016014 3.74879C0.017014 3.25563 0.115008 2.76747 0.304008 2.31218C0.494008 1.8569 0.771003 1.44339 1.12 1.09529C1.468 0.747176 1.88201 0.471286 2.33801 0.283356C2.79301 0.0954257 3.28101 -0.000854289 3.77401 5.71079e-06L13.256 0.0165758C14.251 0.0183158 15.204 0.415636 15.906 1.12113C16.608 1.82661 17.002 2.78247 17 3.77844L16.984 13.2512ZM15.759 3.95072C15.759 3.59929 15.691 3.25117 15.557 2.92625C15.423 2.60133 15.227 2.30597 14.979 2.05703C14.731 1.80809 14.437 1.61045 14.113 1.47539C13.789 1.34034 13.442 1.27051 13.091 1.26989L3.924 1.25387C3.573 1.25325 3.22501 1.32186 2.90101 1.45578C2.57601 1.5897 2.28101 1.78631 2.03201 2.03438C1.78401 2.28244 1.586 2.57712 1.452 2.90156C1.317 3.22601 1.24701 3.57389 1.24601 3.92532L1.23101 12.9839C1.23001 13.3353 1.29801 13.6834 1.43201 14.0083C1.56601 14.3333 1.762 14.6286 2.01 14.8776C2.258 15.1265 2.55201 15.3241 2.87601 15.4592C3.20001 15.5943 3.54801 15.6641 3.89901 15.6647L13.065 15.6807C13.416 15.6813 13.764 15.6127 14.089 15.4788C14.413 15.3449 14.708 15.1483 14.957 14.9002C15.205 14.6522 15.403 14.3575 15.538 14.033C15.672 13.7086 15.742 13.3607 15.743 13.0093L15.759 3.95072Z" fill="black"/>
|
||||||
|
<path d="M12.1893 8.01892H5.13272C4.77843 8.01892 4.49121 8.30613 4.49121 8.66043V8.66043C4.49121 9.01473 4.77843 9.30194 5.13272 9.30194H12.1893C12.5436 9.30194 12.8308 9.01473 12.8308 8.66043C12.8308 8.30613 12.5436 8.01892 12.1893 8.01892Z" fill="black"/>
|
||||||
|
<path d="M9.30176 12.1887V5.13211C9.30176 4.77781 9.01454 4.4906 8.66025 4.4906H8.66025C8.30595 4.4906 8.01874 4.77781 8.01874 5.13211V12.1887C8.01874 12.543 8.30595 12.8302 8.66025 12.8302C9.01454 12.8302 9.30176 12.543 9.30176 12.1887Z" fill="black"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_538_4">
|
||||||
|
<rect width="17" height="17" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="33" height="33" viewBox="233.5 233.5 33 33" xml:space="preserve">
|
||||||
|
<desc>Created with Fabric.js 5.3.0</desc>
|
||||||
|
<defs>
|
||||||
|
</defs>
|
||||||
|
<g transform="matrix(0.05 0 0 0.05 250 250)" id="SxIUNrmYlm-UtiEj48uHt" >
|
||||||
|
<path style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(0,122,255); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" transform=" translate(-480, 480)" d="M 480 -160 L 160 -480 L 202 -522 L 450 -274 L 450 -800 L 510 -800 L 510 -274 L 758 -522 L 800 -480 L 480 -160 Z" stroke-linecap="round" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 893 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg width="22" xmlns="http://www.w3.org/2000/svg" height="22" id="screenshot-deae4d7e-8911-807f-8006-d1a9de3c1165" viewBox="0 0 22 22" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1"><g id="shape-deae4d7e-8911-807f-8006-d1a9de3c1165"><defs><clipPath id="frame-clip-deae4d7e-8911-807f-8006-d1a9de3c1165-render-1" class="frame-clip frame-clip-def"><rect rx="0" ry="0" x="0" y="0" width="22" height="22" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)"/></clipPath></defs><g class="frame-container-wrapper"><g class="frame-container-blur"><g class="frame-container-shadows"><g clip-path="url(#frame-clip-deae4d7e-8911-807f-8006-d1a9de3c1165-render-1)" fill="none"><g class="fills" id="fills-deae4d7e-8911-807f-8006-d1a9de3c1165"><rect rx="0" ry="0" x="0" y="0" width="22" height="22" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" class="frame-background"/></g><g class="frame-children"><g id="shape-deae4d7e-8911-807f-8006-d1a99b14a6fe"><g class="fills" id="fills-deae4d7e-8911-807f-8006-d1a99b14a6fe"><ellipse cx="11" cy="11" rx="11" ry="11" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" style="fill: rgb(225, 223, 222); fill-opacity: 1;"/></g></g><g id="shape-deae4d7e-8911-807f-8006-d1aa4e1dc3bb"><g class="fills" id="fills-deae4d7e-8911-807f-8006-d1aa4e1dc3bb"><ellipse cx="14" cy="11" rx="1" ry="1" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" style="fill: rgb(0, 0, 0); fill-opacity: 1;"/></g></g><g id="shape-529e0883-3585-806e-8006-d33b61f8d93b"><g class="fills" id="fills-529e0883-3585-806e-8006-d33b61f8d93b"><ellipse cx="11" cy="11" rx="1" ry="1" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" style="fill: rgb(0, 0, 0); fill-opacity: 1;"/></g></g><g id="shape-529e0883-3585-806e-8006-d33b658407f5"><g class="fills" id="fills-529e0883-3585-806e-8006-d33b658407f5"><ellipse cx="8" cy="11" rx="1" ry="1" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" style="fill: rgb(0, 0, 0); fill-opacity: 1;"/></g></g></g></g></g></g></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg width="22" xmlns="http://www.w3.org/2000/svg" height="22" id="screenshot-deae4d7e-8911-807f-8006-d1ab0933fe5c" viewBox="0 0 22 22" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1"><g id="shape-deae4d7e-8911-807f-8006-d1ab0933fe5c"><defs><clipPath id="frame-clip-deae4d7e-8911-807f-8006-d1ab0933fe5c-render-1" class="frame-clip frame-clip-def"><rect rx="0" ry="0" x="0" y="0" width="22" height="22" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)"/></clipPath></defs><g class="frame-container-wrapper"><g class="frame-container-blur"><g class="frame-container-shadows"><g clip-path="url(#frame-clip-deae4d7e-8911-807f-8006-d1ab0933fe5c-render-1)" fill="none"><g class="fills" id="fills-deae4d7e-8911-807f-8006-d1ab0933fe5c"><rect rx="0" ry="0" x="0" y="0" width="22" height="22" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" class="frame-background"/></g><g class="frame-children"><g id="shape-deae4d7e-8911-807f-8006-d1ab0933fe5d"><g class="fills" id="fills-deae4d7e-8911-807f-8006-d1ab0933fe5d"><ellipse cx="11" cy="11" rx="11" ry="11" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" style="fill: rgb(252, 252, 252); fill-opacity: 1;"/></g></g><g id="shape-deae4d7e-8911-807f-8006-d1ab0933fe5e"><g class="fills" id="fills-deae4d7e-8911-807f-8006-d1ab0933fe5e"><ellipse cx="15" cy="11" rx="1" ry="1" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" style="fill: rgb(0, 0, 0); fill-opacity: 1;"/></g></g><g id="shape-529e0883-3585-806e-8006-d33ad86e6a89"><g class="fills" id="fills-529e0883-3585-806e-8006-d33ad86e6a89"><ellipse cx="11" cy="11" rx="1" ry="1" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" style="fill: rgb(0, 0, 0); fill-opacity: 1;"/></g></g><g id="shape-529e0883-3585-806e-8006-d33adbcd1201"><g class="fills" id="fills-529e0883-3585-806e-8006-d33adbcd1201"><ellipse cx="7" cy="11" rx="1" ry="1" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" style="fill: rgb(0, 0, 0); fill-opacity: 1;"/></g></g></g></g></g></g></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg width="17" xmlns="http://www.w3.org/2000/svg" height="22" id="screenshot-deae4d7e-8911-807f-8006-d1a8485085de" viewBox="0 0 17 22" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1"><g id="shape-deae4d7e-8911-807f-8006-d1a8485085de" width="17" height="22" rx="0" ry="0" style="fill: rgb(0, 0, 0);"><g id="shape-deae4d7e-8911-807f-8006-d1a848516fae" style="display: none;"><g class="fills" id="fills-deae4d7e-8911-807f-8006-d1a848516fae"><rect width="17" height="22" x="0" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" style="fill: none;" ry="0" fill="none" rx="0" y="0"/></g></g><g id="shape-deae4d7e-8911-807f-8006-d1a848521481"><g class="fills" id="fills-deae4d7e-8911-807f-8006-d1a848521481"><path d="M2.768157958984375,21.20001220703125L13.631805419921875,21.20001220703125C14.571990966796875,21.20001220703125,15.251007080078125,20.9910888671875,15.72100830078125,20.52099609375C16.19110107421875,20.0509033203125,16.399993896484375,19.37188720703125,16.399993896484375,18.484130859375L16.399993896484375,8.7694091796875C16.399993896484375,7.8814697265625,16.19110107421875,7.2025146484375,15.72100830078125,6.73248291015625C15.251007080078125,6.26239013671875,14.571990966796875,6.053466796875,13.631805419921875,6.053466796875L10.96820068359375,6.053466796875L10.96820068359375,7.2025146484375L13.631805419921875,7.2025146484375C14.154098510742188,7.2025146484375,14.571990966796875,7.35919189453125,14.833099365234375,7.620361328125C15.09429931640625,7.8814697265625,15.251007080078125,8.29931640625,15.251007080078125,8.87384033203125L15.251007080078125,18.37957763671875C15.251007080078125,18.90191650390625,15.09429931640625,19.3197021484375,14.833099365234375,19.63311767578125C14.571990966796875,19.94647216796875,14.154098510742188,20.0509033203125,13.631805419921875,20.0509033203125L2.82037353515625,20.0509033203125C2.298095703125,20.0509033203125,1.8802490234375,19.89422607421875,1.619110107421875,19.63311767578125C1.3579559326171875,19.37188720703125,1.201263427734375,18.9541015625,1.201263427734375,18.37957763671875L1.201263427734375,8.8216552734375C1.201263427734375,8.29931640625,1.3579559326171875,7.8814697265625,1.619110107421875,7.568115234375C1.8802490234375,7.2547607421875,2.298095703125,7.1502685546875,2.82037353515625,7.1502685546875L5.4840850830078125,7.1502685546875L5.4840850830078125,6.001220703125L2.768157958984375,6.001220703125C1.828033447265625,6.001220703125,1.1490478515625,6.21014404296875,0.678985595703125,6.68023681640625C0.20892333984375,7.1502685546875,0,7.8814697265625,0,8.7694091796875L0,18.43182373046875C0,19.3197021484375,0.20892333984375,20.0509033203125,0.678985595703125,20.46881103515625C1.1490478515625,20.8865966796875,1.8802490234375,21.20001220703125,2.768157958984375,21.20001220703125ZM8.199996948242188,13.94012451171875C8.356689453125,13.94012451171875,8.513381958007812,13.88787841796875,8.617828369140625,13.78338623046875C8.7222900390625,13.67901611328125,8.774520874023438,13.52227783203125,8.774520874023438,13.3656005859375L8.774520874023438,3.18084716796875L8.7222900390625,1.8228759765625L9.557952880859375,2.7108154296875L11.177093505859375,4.38214111328125C11.281494140625,4.486572265625,11.438201904296875,4.538818359375,11.594894409179688,4.538818359375C11.751602172851562,4.538818359375,11.856094360351562,4.486572265625,11.960494995117188,4.38214111328125C12.06500244140625,4.2777099609375,12.117202758789062,4.1732177734375,12.117202758789062,4.01654052734375C12.117202758789062,3.85986328125,12.06500244140625,3.75537109375,11.960494995117188,3.65093994140625L8.617828369140625,0.4127197265625C8.565597534179688,0.3604736328125,8.461151123046875,0.3082275390625,8.408920288085938,0.25604248046875C8.356689453125,0.20379638671875,8.304458618164062,0.20379638671875,8.199996948242188,0.20379638671875C8.14776611328125,0.20379638671875,8.043304443359375,0.20379638671875,7.9910888671875,0.25604248046875C7.9388427734375,0.3082275390625,7.834381103515625,0.3604736328125,7.78216552734375,0.4127197265625L4.491729736328125,3.65093994140625C4.33502197265625,3.75537109375,4.282806396484375,3.85986328125,4.282806396484375,4.01654052734375C4.282806396484375,4.1732177734375,4.33502197265625,4.2777099609375,4.439483642578125,4.38214111328125C4.5439453125,4.486572265625,4.648406982421875,4.538818359375,4.8050994873046875,4.538818359375C4.9617767333984375,4.538818359375,5.11846923828125,4.486572265625,5.222930908203125,4.38214111328125L6.842041015625,2.7108154296875L7.677703857421875,1.8228759765625L7.62548828125,3.18084716796875L7.62548828125,13.3656005859375C7.62548828125,13.52227783203125,7.677703857421875,13.626708984375,7.78216552734375,13.78338623046875C7.886627197265625,13.94012451171875,8.043304443359375,13.94012451171875,8.199996948242188,13.94012451171875Z" style="fill: rgb(0, 0, 0); fill-opacity: 1;"/></g></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 4.8 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="17" height="22" viewBox="0 0 17 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M2.76815 21.2H13.6318C14.572 21.2 15.251 20.9911 15.721 20.521C16.1911 20.0509 16.4 19.3719 16.4 18.4841V8.7694C16.4 7.8815 16.1911 7.20252 15.721 6.73246C15.251 6.26239 14.572 6.05348 13.6318 6.05348H10.9682V7.20252H13.6318C14.1541 7.20252 14.572 7.35921 14.8331 7.62036C15.0943 7.8815 15.251 8.29934 15.251 8.87386V18.3796C15.251 18.9019 15.0943 19.3197 14.8331 19.6331C14.572 19.9465 14.1541 20.0509 13.6318 20.0509H2.82038C2.29809 20.0509 1.88025 19.8942 1.61911 19.6331C1.35796 19.3719 1.20127 18.9541 1.20127 18.3796V8.82163C1.20127 8.29934 1.35796 7.8815 1.61911 7.56813C1.88025 7.25475 2.29809 7.15029 2.82038 7.15029H5.48408V6.00125H2.76815C1.82803 6.00125 1.14904 6.21017 0.678981 6.68023C0.208917 7.15029 0 7.8815 0 8.7694V18.4318C0 19.3197 0.208917 20.0509 0.678981 20.4688C1.14904 20.8866 1.88025 21.2 2.76815 21.2ZM8.2 13.9401C8.35669 13.9401 8.51338 13.8879 8.61783 13.7834C8.72229 13.679 8.77452 13.5223 8.77452 13.3656V3.18087L8.72229 1.8229L9.55796 2.7108L11.1771 4.38214C11.2815 4.4866 11.4382 4.53883 11.5949 4.53883C11.7516 4.53883 11.8561 4.4866 11.9605 4.38214C12.065 4.27768 12.1172 4.17322 12.1172 4.01654C12.1172 3.85985 12.065 3.75539 11.9605 3.65093L8.61783 0.412714C8.5656 0.360484 8.46115 0.308255 8.40892 0.256026C8.35669 0.203796 8.30446 0.203796 8.2 0.203796C8.14777 0.203796 8.04331 0.203796 7.99108 0.256026C7.93885 0.308255 7.83439 0.360484 7.78217 0.412714L4.49172 3.65093C4.33503 3.75539 4.2828 3.85985 4.2828 4.01654C4.2828 4.17322 4.33503 4.27768 4.43949 4.38214C4.54395 4.4866 4.64841 4.53883 4.8051 4.53883C4.96178 4.53883 5.11847 4.4866 5.22293 4.38214L6.84204 2.7108L7.67771 1.8229L7.62548 3.18087V13.3656C7.62548 13.5223 7.67771 13.6267 7.78217 13.7834C7.88662 13.9401 8.04331 13.9401 8.2 13.9401Z" fill="#007AFF"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
BIN
src/static/add-to-homescreen/assets/img/openinsafari-button.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
270
src/static/add-to-homescreen/roboto.css
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
/* cyrillic-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-stretch: 100%;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3GUBGEe.woff2) format('woff2');
|
||||||
|
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
|
}
|
||||||
|
/* cyrillic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-stretch: 100%;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3iUBGEe.woff2) format('woff2');
|
||||||
|
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
|
}
|
||||||
|
/* greek-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-stretch: 100%;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3CUBGEe.woff2) format('woff2');
|
||||||
|
unicode-range: U+1F00-1FFF;
|
||||||
|
}
|
||||||
|
/* greek */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-stretch: 100%;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3-UBGEe.woff2) format('woff2');
|
||||||
|
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
|
||||||
|
}
|
||||||
|
/* math */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-stretch: 100%;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMawCUBGEe.woff2) format('woff2');
|
||||||
|
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
|
||||||
|
}
|
||||||
|
/* symbols */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-stretch: 100%;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMaxKUBGEe.woff2) format('woff2');
|
||||||
|
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
|
||||||
|
}
|
||||||
|
/* vietnamese */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-stretch: 100%;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3OUBGEe.woff2) format('woff2');
|
||||||
|
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-stretch: 100%;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBGEe.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-stretch: 100%;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBA.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* cyrillic-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-stretch: 100%;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3GUBGEe.woff2) format('woff2');
|
||||||
|
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
|
}
|
||||||
|
/* cyrillic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-stretch: 100%;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3iUBGEe.woff2) format('woff2');
|
||||||
|
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
|
}
|
||||||
|
/* greek-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-stretch: 100%;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3CUBGEe.woff2) format('woff2');
|
||||||
|
unicode-range: U+1F00-1FFF;
|
||||||
|
}
|
||||||
|
/* greek */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-stretch: 100%;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3-UBGEe.woff2) format('woff2');
|
||||||
|
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
|
||||||
|
}
|
||||||
|
/* math */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-stretch: 100%;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMawCUBGEe.woff2) format('woff2');
|
||||||
|
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
|
||||||
|
}
|
||||||
|
/* symbols */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-stretch: 100%;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMaxKUBGEe.woff2) format('woff2');
|
||||||
|
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
|
||||||
|
}
|
||||||
|
/* vietnamese */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-stretch: 100%;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3OUBGEe.woff2) format('woff2');
|
||||||
|
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-stretch: 100%;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBGEe.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-stretch: 100%;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBA.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* cyrillic-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-stretch: 100%;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3GUBGEe.woff2) format('woff2');
|
||||||
|
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
|
}
|
||||||
|
/* cyrillic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-stretch: 100%;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3iUBGEe.woff2) format('woff2');
|
||||||
|
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
|
}
|
||||||
|
/* greek-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-stretch: 100%;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3CUBGEe.woff2) format('woff2');
|
||||||
|
unicode-range: U+1F00-1FFF;
|
||||||
|
}
|
||||||
|
/* greek */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-stretch: 100%;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3-UBGEe.woff2) format('woff2');
|
||||||
|
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
|
||||||
|
}
|
||||||
|
/* math */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-stretch: 100%;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMawCUBGEe.woff2) format('woff2');
|
||||||
|
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
|
||||||
|
}
|
||||||
|
/* symbols */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-stretch: 100%;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMaxKUBGEe.woff2) format('woff2');
|
||||||
|
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
|
||||||
|
}
|
||||||
|
/* vietnamese */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-stretch: 100%;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3OUBGEe.woff2) format('woff2');
|
||||||
|
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-stretch: 100%;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBGEe.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-stretch: 100%;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBA.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#0f0f1a" />
|
<meta name="theme-color" content="#0f0f1a" />
|
||||||
<title>Lagomare Gates - Admin</title>
|
<title>Lagomare Gates - Admin</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/logo.svg" />
|
<link rel="icon" type="image/svg+xml" href="/static/images/logo.svg" />
|
||||||
<link rel="stylesheet" href="/static/style.css" />
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
.tab-btn.active { color: var(--primary); border-bottom-color: var(--primary); }
|
.tab-btn.active { color: var(--primary); border-bottom-color: var(--primary); }
|
||||||
|
|
||||||
/* ── Tab panes ──────────────────────────────────────────────────────── */
|
/* ── Tab panes ──────────────────────────────────────────────────────── */
|
||||||
.tab-content { flex: 1; padding: 1.5rem; max-width: 960px; margin: 0 auto; width: 100%; }
|
.tab-content { flex: 1; padding: 1.5rem; max-width: 1200px; margin: 0 auto; width: 100%; }
|
||||||
.tab-pane { display: none; }
|
.tab-pane { display: none; }
|
||||||
.tab-pane.active { display: block; }
|
.tab-pane.active { display: block; }
|
||||||
|
|
||||||
@@ -78,6 +78,20 @@
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
.section-header h3 { font-size: 1rem; font-weight: 700; }
|
.section-header h3 { font-size: 1rem; font-weight: 700; }
|
||||||
|
|
||||||
|
/* ── Gate icon picker ───────────────────────────────────────────────── */
|
||||||
|
.icon-opt {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
border-radius: .4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: .3rem .45rem;
|
||||||
|
transition: border-color .12s, background .12s;
|
||||||
|
}
|
||||||
|
.icon-opt:hover { border-color: var(--primary); }
|
||||||
|
.icon-opt.selected { border-color: var(--primary); background: color-mix(in srgb, var(--primary) 15%, transparent); }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -97,6 +111,12 @@
|
|||||||
<label for="admin-password">Password</label>
|
<label for="admin-password">Password</label>
|
||||||
<input id="admin-password" type="password" autocomplete="current-password" />
|
<input id="admin-password" type="password" autocomplete="current-password" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field hidden" id="otp-field">
|
||||||
|
<label for="admin-otp">Authenticator code</label>
|
||||||
|
<input id="admin-otp" type="text" inputmode="numeric" pattern="[0-9]{6}"
|
||||||
|
autocomplete="one-time-code" maxlength="6" placeholder="6-digit code"
|
||||||
|
style="font-family:monospace;font-size:1.3rem;letter-spacing:.2em;text-align:center" />
|
||||||
|
</div>
|
||||||
<p id="login-error" class="error-msg hidden"></p>
|
<p id="login-error" class="error-msg hidden"></p>
|
||||||
<button type="submit" class="btn btn-primary btn-full" style="margin-top:.25rem">Sign in</button>
|
<button type="submit" class="btn btn-primary btn-full" style="margin-top:.25rem">Sign in</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -116,9 +136,11 @@
|
|||||||
<nav class="tabs">
|
<nav class="tabs">
|
||||||
<button class="tab-btn active" data-tab="keypasses">Keypasses</button>
|
<button class="tab-btn active" data-tab="keypasses">Keypasses</button>
|
||||||
<button class="tab-btn" data-tab="gates">Gates</button>
|
<button class="tab-btn" data-tab="gates">Gates</button>
|
||||||
<button class="tab-btn admin-only" data-tab="credentials">AVConnect Credentials</button>
|
|
||||||
<button class="tab-btn" data-tab="stats">Statistics</button>
|
<button class="tab-btn" data-tab="stats">Statistics</button>
|
||||||
|
<button class="tab-btn admin-only" data-tab="logs">Logs</button>
|
||||||
<button class="tab-btn admin-only" data-tab="admins">Admins</button>
|
<button class="tab-btn admin-only" data-tab="admins">Admins</button>
|
||||||
|
<button class="tab-btn admin-only" data-tab="credentials">API Credentials</button>
|
||||||
|
<button class="tab-btn admin-only" data-tab="telegram">Notifications</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
@@ -137,6 +159,7 @@
|
|||||||
<th>Description</th>
|
<th>Description</th>
|
||||||
<th>Gates</th>
|
<th>Gates</th>
|
||||||
<th>Expires</th>
|
<th>Expires</th>
|
||||||
|
<th>Schedule</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -158,8 +181,10 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Type</th>
|
<th>Group</th>
|
||||||
<th>AVConnect Macro ID</th>
|
<th>Icon</th>
|
||||||
|
<th>Provider</th>
|
||||||
|
<th>Device / Macro ID</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -171,7 +196,10 @@
|
|||||||
|
|
||||||
<!-- ── Credentials pane ───────────────────────────────────────────── -->
|
<!-- ── Credentials pane ───────────────────────────────────────────── -->
|
||||||
<div id="tab-credentials" class="tab-pane">
|
<div id="tab-credentials" class="tab-pane">
|
||||||
<h3 style="margin-bottom:1rem">AVConnect Credentials</h3>
|
<h3 style="margin-bottom:1rem">API Credentials</h3>
|
||||||
|
|
||||||
|
<!-- AVConnect -->
|
||||||
|
<h4 style="margin:0 0 .75rem">AVConnect</h4>
|
||||||
<div class="card" style="max-width:440px">
|
<div class="card" style="max-width:440px">
|
||||||
<form id="credentials-form">
|
<form id="credentials-form">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -187,6 +215,36 @@
|
|||||||
<button type="submit" class="btn btn-primary">Save</button>
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Shelly Cloud -->
|
||||||
|
<h4 style="margin:1.5rem 0 .75rem">Shelly Cloud</h4>
|
||||||
|
<div class="card" style="max-width:440px">
|
||||||
|
<form id="shelly-credentials-form">
|
||||||
|
<div class="field">
|
||||||
|
<label for="shelly-server-uri">Server URI</label>
|
||||||
|
<input id="shelly-server-uri" type="url" autocomplete="off"
|
||||||
|
placeholder="e.g. https://shelly-3.eu.shelly.cloud" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="shelly-auth-key">Auth Key</label>
|
||||||
|
<input id="shelly-auth-key" type="password" autocomplete="new-password"
|
||||||
|
placeholder="Leave empty to keep current" />
|
||||||
|
</div>
|
||||||
|
<p id="shelly-cred-error" class="error-msg hidden"></p>
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style="margin:1.5rem 0 1rem">Mock Mode</h3>
|
||||||
|
<div class="card" style="max-width:440px">
|
||||||
|
<p style="color:var(--text-muted);font-size:.9rem;margin-bottom:1rem">
|
||||||
|
When enabled, gate open requests always succeed without contacting any API.
|
||||||
|
</p>
|
||||||
|
<label style="display:flex;align-items:center;gap:.75rem;cursor:pointer;margin:0">
|
||||||
|
<input type="checkbox" id="mock-toggle" style="width:1.1rem;height:1.1rem;flex-shrink:0;cursor:pointer" />
|
||||||
|
<span style="font-weight:600">Enable mock mode</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Statistics pane ───────────────────────────────────────────── -->
|
<!-- ── Statistics pane ───────────────────────────────────────────── -->
|
||||||
@@ -195,6 +253,40 @@
|
|||||||
<h3>Gate Access Log</h3>
|
<h3>Gate Access Log</h3>
|
||||||
<button id="btn-refresh-stats" class="btn btn-ghost" style="font-size:.85rem;padding:.5rem 1rem">↻ Refresh</button>
|
<button id="btn-refresh-stats" class="btn btn-ghost" style="font-size:.85rem;padding:.5rem 1rem">↻ Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter bar -->
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:.75rem;margin-bottom:1rem;align-items:flex-end">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:.3rem">
|
||||||
|
<label style="font-size:.8rem;font-weight:600;color:var(--text-muted)">Keypass code</label>
|
||||||
|
<input id="filter-keypass" type="text" placeholder="Any"
|
||||||
|
style="width:140px;font-family:monospace;text-transform:uppercase" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:.3rem">
|
||||||
|
<label style="font-size:.8rem;font-weight:600;color:var(--text-muted)">Gate</label>
|
||||||
|
<select id="filter-gate" style="width:160px">
|
||||||
|
<option value="">Any</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:.3rem">
|
||||||
|
<label style="font-size:.8rem;font-weight:600;color:var(--text-muted)">Result</label>
|
||||||
|
<select id="filter-success" style="width:110px">
|
||||||
|
<option value="">Any</option>
|
||||||
|
<option value="true">Success</option>
|
||||||
|
<option value="false">Failed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:.3rem">
|
||||||
|
<label style="font-size:.8rem;font-weight:600;color:var(--text-muted)">From</label>
|
||||||
|
<input id="filter-from" type="datetime-local" style="width:180px" />
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:.3rem">
|
||||||
|
<label style="font-size:.8rem;font-weight:600;color:var(--text-muted)">To</label>
|
||||||
|
<input id="filter-to" type="datetime-local" style="width:180px" />
|
||||||
|
</div>
|
||||||
|
<button id="btn-stats-filter" class="btn btn-primary" style="font-size:.85rem;padding:.5rem 1rem">Filter</button>
|
||||||
|
<button id="btn-stats-reset" class="btn btn-ghost" style="font-size:.85rem;padding:.5rem 1rem">Reset</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="table-wrap card" style="padding:0">
|
<div class="table-wrap card" style="padding:0">
|
||||||
<table id="stats-table">
|
<table id="stats-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -210,6 +302,16 @@
|
|||||||
<tbody id="stats-body"></tbody>
|
<tbody id="stats-body"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-top:1rem;font-size:.9rem;flex-wrap:wrap;gap:.5rem">
|
||||||
|
<span id="stats-total-label" style="color:var(--text-muted)"></span>
|
||||||
|
<div style="display:flex;gap:.5rem;align-items:center">
|
||||||
|
<button id="btn-stats-prev" class="btn btn-ghost" style="font-size:.85rem;padding:.4rem .9rem">← Prev</button>
|
||||||
|
<span id="stats-page-label" style="color:var(--text-muted);min-width:90px;text-align:center"></span>
|
||||||
|
<button id="btn-stats-next" class="btn btn-ghost" style="font-size:.85rem;padding:.4rem .9rem">Next →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Admins pane ──────────────────────────────────────────────────── -->
|
<!-- ── Admins pane ──────────────────────────────────────────────────── -->
|
||||||
@@ -231,6 +333,90 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Telegram / Notifications pane ────────────────────────────────── -->
|
||||||
|
<div id="tab-telegram" class="tab-pane">
|
||||||
|
<h3 style="margin-bottom:1rem">Telegram Notifications</h3>
|
||||||
|
<div class="card" style="max-width:480px">
|
||||||
|
<p style="color:var(--text-muted);font-size:.9rem;margin-bottom:1.25rem">
|
||||||
|
Send a message to a Telegram group or chat every time a gate is opened.
|
||||||
|
Create a bot via <a href="https://t.me/BotFather" target="_blank" rel="noopener" style="color:var(--primary)">@BotFather</a>,
|
||||||
|
add it to your group, and paste its token and the chat ID below.
|
||||||
|
</p>
|
||||||
|
<form id="telegram-form">
|
||||||
|
<div class="field">
|
||||||
|
<label for="tg-token">Bot Token</label>
|
||||||
|
<input id="tg-token" type="password" autocomplete="off"
|
||||||
|
placeholder="Leave empty to keep current" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="tg-chat-id">Chat / Group ID</label>
|
||||||
|
<input id="tg-chat-id" type="text" autocomplete="off"
|
||||||
|
placeholder="e.g. -1001234567890" required />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label style="display:flex;align-items:center;gap:.75rem;cursor:pointer;margin:0">
|
||||||
|
<input type="checkbox" id="tg-enabled" checked style="width:1.1rem;height:1.1rem;flex-shrink:0;cursor:pointer" />
|
||||||
|
<span style="font-weight:600">Enable notifications</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p id="tg-status" style="font-size:.85rem;color:var(--text-muted);margin-bottom:.75rem"></p>
|
||||||
|
<p id="tg-error" class="error-msg hidden"></p>
|
||||||
|
<div style="display:flex;gap:.75rem;flex-wrap:wrap">
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<button type="button" id="btn-tg-test" class="btn btn-ghost">Send test message</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Logs pane ──────────────────────────────────────────────────── -->
|
||||||
|
<div id="tab-logs" class="tab-pane">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>Application Logs</h3>
|
||||||
|
<div style="display:flex;gap:.5rem;align-items:center">
|
||||||
|
<label style="display:flex;align-items:center;gap:.4rem;font-size:.85rem;color:var(--text-muted);cursor:pointer">
|
||||||
|
<input type="checkbox" id="logs-autorefresh" style="width:1rem;height:1rem;cursor:pointer" />
|
||||||
|
Auto-refresh
|
||||||
|
</label>
|
||||||
|
<button id="btn-refresh-logs" class="btn btn-ghost" style="font-size:.85rem;padding:.5rem 1rem">↻ Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter bar -->
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:.75rem;margin-bottom:1rem;align-items:flex-end">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:.3rem">
|
||||||
|
<label style="font-size:.8rem;font-weight:600;color:var(--text-muted)">Min level</label>
|
||||||
|
<select id="logs-level" style="width:130px">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="DEBUG">DEBUG</option>
|
||||||
|
<option value="INFO" selected>INFO</option>
|
||||||
|
<option value="WARNING">WARNING</option>
|
||||||
|
<option value="ERROR">ERROR</option>
|
||||||
|
<option value="CRITICAL">CRITICAL</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:.3rem">
|
||||||
|
<label style="font-size:.8rem;font-weight:600;color:var(--text-muted)">Lines (tail)</label>
|
||||||
|
<input id="logs-lines" type="number" value="200" min="10" max="2000"
|
||||||
|
style="width:90px" />
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:.3rem">
|
||||||
|
<label style="font-size:.8rem;font-weight:600;color:var(--text-muted)">Filter text</label>
|
||||||
|
<input id="logs-search" type="text" placeholder="e.g. ERROR or keypass…"
|
||||||
|
style="width:200px" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="logs-no-file" class="hidden" style="color:var(--text-muted);font-size:.9rem;padding:1rem 0">
|
||||||
|
Log file not configured — set the <code>LOG_FILE</code> environment variable to enable file logging.
|
||||||
|
</div>
|
||||||
|
<div class="card" style="padding:0;overflow:hidden">
|
||||||
|
<pre id="logs-output"
|
||||||
|
style="margin:0;padding:1rem;font-size:.78rem;line-height:1.55;overflow-x:auto;overflow-y:auto;max-height:60vh;white-space:pre-wrap;word-break:break-all;background:var(--surface);color:var(--text)"></pre>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:.5rem;font-size:.8rem;color:var(--text-muted)" id="logs-meta"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div><!-- /.tab-content -->
|
</div><!-- /.tab-content -->
|
||||||
</div><!-- /#admin-view -->
|
</div><!-- /#admin-view -->
|
||||||
|
|
||||||
@@ -247,7 +433,23 @@
|
|||||||
<label for="kp-code">Code <span style="color:var(--text-muted);font-weight:400">(leave empty to auto-generate)</span></label>
|
<label for="kp-code">Code <span style="color:var(--text-muted);font-weight:400">(leave empty to auto-generate)</span></label>
|
||||||
<input id="kp-code" type="text" autocomplete="off" autocorrect="off" autocapitalize="characters"
|
<input id="kp-code" type="text" autocomplete="off" autocorrect="off" autocapitalize="characters"
|
||||||
spellcheck="false" placeholder="Auto-generated"
|
spellcheck="false" placeholder="Auto-generated"
|
||||||
style="font-family:monospace;letter-spacing:.1em;text-transform:uppercase" maxlength="32" />
|
style="font-family:monospace;letter-spacing:.1em;text-transform:uppercase" maxlength="40" />
|
||||||
|
</div>
|
||||||
|
<!-- Auto-generation options — hidden when a manual code is typed -->
|
||||||
|
<div id="kp-autogen-options" class="field" style="background:var(--surface2);border-radius:8px;padding:.75rem;border:1px solid var(--border);display:flex;flex-wrap:wrap;gap:.75rem;align-items:flex-end">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:.3rem">
|
||||||
|
<label for="kp-charset" style="font-size:.8rem;font-weight:600;color:var(--text-muted)">Character set</label>
|
||||||
|
<select id="kp-charset" style="width:auto;font-size:.9rem">
|
||||||
|
<option value="alphanumeric">A–Z + 0–9</option>
|
||||||
|
<option value="alpha">A–Z only</option>
|
||||||
|
<option value="numeric">0–9 only</option>
|
||||||
|
<option value="passphrase" selected>Passphrase (4 words)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="kp-length-wrap" style="display:flex;flex-direction:column;gap:.3rem">
|
||||||
|
<label for="kp-length" style="font-size:.8rem;font-weight:600;color:var(--text-muted)">Length <span id="kp-length-val" style="font-weight:700;color:var(--text)">12</span></label>
|
||||||
|
<input id="kp-length" type="range" min="6" max="32" value="12" style="width:100%;cursor:pointer" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field" id="kp-expires-field">
|
<div class="field" id="kp-expires-field">
|
||||||
<label for="kp-expires">Expiry date & time</label>
|
<label for="kp-expires">Expiry date & time</label>
|
||||||
@@ -267,6 +469,38 @@
|
|||||||
<div id="kp-gate-checks" style="display:flex;flex-direction:column;gap:.3rem"></div>
|
<div id="kp-gate-checks" style="display:flex;flex-direction:column;gap:.3rem"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label style="margin-bottom:.5rem">Access schedule</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:.75rem;cursor:pointer;color:var(--text);margin-bottom:.4rem">
|
||||||
|
<input type="checkbox" id="kp-no-schedule" checked style="width:1rem;height:1rem;flex-shrink:0" />
|
||||||
|
<span style="font-size:.9rem">Always accessible (no restriction)</span>
|
||||||
|
</label>
|
||||||
|
<div id="kp-schedule-wrap" style="display:none;flex-direction:column;gap:.75rem;padding:.75rem;background:var(--surface2);border-radius:8px;border:1px solid var(--border)">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:.8rem;font-weight:600;color:var(--text-muted);margin-bottom:.4rem">Allowed days <span style="font-weight:400">(leave all unchecked for any day)</span></div>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:.4rem">
|
||||||
|
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-day" value="0" style="width:.9rem;height:.9rem" /> Mon</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-day" value="1" style="width:.9rem;height:.9rem" /> Tue</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-day" value="2" style="width:.9rem;height:.9rem" /> Wed</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-day" value="3" style="width:.9rem;height:.9rem" /> Thu</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-day" value="4" style="width:.9rem;height:.9rem" /> Fri</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-day" value="5" style="width:.9rem;height:.9rem" /> Sat</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-day" value="6" style="width:.9rem;height:.9rem" /> Sun</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:.75rem;align-items:flex-end">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:.3rem">
|
||||||
|
<label for="kp-time-start" style="font-size:.8rem;font-weight:600;color:var(--text-muted)">From</label>
|
||||||
|
<input id="kp-time-start" type="time" style="width:auto;font-size:.9rem" />
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:.3rem">
|
||||||
|
<label for="kp-time-end" style="font-size:.8rem;font-weight:600;color:var(--text-muted)">To</label>
|
||||||
|
<input id="kp-time-end" type="time" style="width:auto;font-size:.9rem" />
|
||||||
|
</div>
|
||||||
|
<span style="font-size:.8rem;color:var(--text-muted);padding-bottom:.35rem">(server local time)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<p id="kp-error" class="error-msg hidden"></p>
|
<p id="kp-error" class="error-msg hidden"></p>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="button" id="kp-cancel" class="btn btn-ghost">Cancel</button>
|
<button type="button" id="kp-cancel" class="btn btn-ghost">Cancel</button>
|
||||||
@@ -304,6 +538,38 @@
|
|||||||
<div id="kp-edit-gate-checks" style="display:flex;flex-direction:column;gap:.3rem"></div>
|
<div id="kp-edit-gate-checks" style="display:flex;flex-direction:column;gap:.3rem"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label style="margin-bottom:.5rem">Access schedule</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:.75rem;cursor:pointer;color:var(--text);margin-bottom:.4rem">
|
||||||
|
<input type="checkbox" id="kp-edit-no-schedule" checked style="width:1rem;height:1rem;flex-shrink:0" />
|
||||||
|
<span style="font-size:.9rem">Always accessible (no restriction)</span>
|
||||||
|
</label>
|
||||||
|
<div id="kp-edit-schedule-wrap" style="display:none;flex-direction:column;gap:.75rem;padding:.75rem;background:var(--surface2);border-radius:8px;border:1px solid var(--border)">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:.8rem;font-weight:600;color:var(--text-muted);margin-bottom:.4rem">Allowed days <span style="font-weight:400">(leave all unchecked for any day)</span></div>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:.4rem">
|
||||||
|
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-edit-day" value="0" style="width:.9rem;height:.9rem" /> Mon</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-edit-day" value="1" style="width:.9rem;height:.9rem" /> Tue</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-edit-day" value="2" style="width:.9rem;height:.9rem" /> Wed</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-edit-day" value="3" style="width:.9rem;height:.9rem" /> Thu</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-edit-day" value="4" style="width:.9rem;height:.9rem" /> Fri</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-edit-day" value="5" style="width:.9rem;height:.9rem" /> Sat</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-edit-day" value="6" style="width:.9rem;height:.9rem" /> Sun</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:.75rem;align-items:flex-end">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:.3rem">
|
||||||
|
<label for="kp-edit-time-start" style="font-size:.8rem;font-weight:600;color:var(--text-muted)">From</label>
|
||||||
|
<input id="kp-edit-time-start" type="time" style="width:auto;font-size:.9rem" />
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:.3rem">
|
||||||
|
<label for="kp-edit-time-end" style="font-size:.8rem;font-weight:600;color:var(--text-muted)">To</label>
|
||||||
|
<input id="kp-edit-time-end" type="time" style="width:auto;font-size:.9rem" />
|
||||||
|
</div>
|
||||||
|
<span style="font-size:.8rem;color:var(--text-muted);padding-bottom:.35rem">(server local time)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<p id="kp-edit-error" class="error-msg hidden"></p>
|
<p id="kp-edit-error" class="error-msg hidden"></p>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="button" id="kp-edit-cancel" class="btn btn-ghost">Cancel</button>
|
<button type="button" id="kp-edit-cancel" class="btn btn-ghost">Cancel</button>
|
||||||
@@ -324,15 +590,52 @@
|
|||||||
<input id="gate-name" type="text" placeholder="e.g. Main entrance - Car" required />
|
<input id="gate-name" type="text" placeholder="e.g. Main entrance - Car" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="gate-type">Type</label>
|
<label for="gate-group-name">Group <span style="color:var(--text-muted);font-weight:400">(optional)</span></label>
|
||||||
<select id="gate-type">
|
<input id="gate-group-name" type="text" placeholder="e.g. Main entrance" list="gate-group-list" autocomplete="off" />
|
||||||
<option value="car">Car</option>
|
<datalist id="gate-group-list"></datalist>
|
||||||
<option value="pedestrian">Pedestrian</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
<label>Icon</label>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:.5rem">
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:.35rem" id="gate-icon-grid">
|
||||||
|
<button type="button" class="icon-opt" data-icon="🚘">🚘</button>
|
||||||
|
<button type="button" class="icon-opt" data-icon="🚗">🚗</button>
|
||||||
|
<button type="button" class="icon-opt" data-icon="🚙">🚙</button>
|
||||||
|
<button type="button" class="icon-opt" data-icon="🚕">🚕</button>
|
||||||
|
<button type="button" class="icon-opt" data-icon="🚌">🚌</button>
|
||||||
|
<button type="button" class="icon-opt" data-icon="🚛">🚛</button>
|
||||||
|
<button type="button" class="icon-opt" data-icon="🚲">🚲</button>
|
||||||
|
<button type="button" class="icon-opt" data-icon="🏍️">🏍️</button>
|
||||||
|
<button type="button" class="icon-opt" data-icon="🚶">🚶</button>
|
||||||
|
<button type="button" class="icon-opt" data-icon="🧍">🧍</button>
|
||||||
|
<button type="button" class="icon-opt" data-icon="🚪">🚪</button>
|
||||||
|
<button type="button" class="icon-opt" data-icon="⛩️">⛩️</button>
|
||||||
|
<button type="button" class="icon-opt" data-icon="🏠">🏠</button>
|
||||||
|
<button type="button" class="icon-opt" data-icon="🏢">🏢</button>
|
||||||
|
<button type="button" class="icon-opt" data-icon="🔒">🔒</button>
|
||||||
|
<button type="button" class="icon-opt" data-icon="🔑">🔑</button>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:.6rem">
|
||||||
|
<span id="gate-icon-preview" style="font-size:1.6rem;line-height:1;min-width:2rem;text-align:center">🚪</span>
|
||||||
|
<input id="gate-icon-input" type="text" placeholder="Type or paste any character…"
|
||||||
|
style="flex:1" maxlength="4" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="gate-api-provider">API Provider</label>
|
||||||
|
<select id="gate-api-provider">
|
||||||
|
<option value="avconnect">AVConnect</option>
|
||||||
|
<option value="shelly">Shelly Cloud</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field" id="gate-avconnect-field">
|
||||||
<label for="gate-avconnect-macro-id">AVConnect Macro ID</label>
|
<label for="gate-avconnect-macro-id">AVConnect Macro ID</label>
|
||||||
<input id="gate-avconnect-macro-id" type="text" placeholder="e.g. 42" required />
|
<input id="gate-avconnect-macro-id" type="text" placeholder="e.g. 42" />
|
||||||
|
</div>
|
||||||
|
<div class="field" id="gate-shelly-field" style="display:none">
|
||||||
|
<label for="gate-shelly-device-id">Shelly Device ID</label>
|
||||||
|
<input id="gate-shelly-device-id" type="text" placeholder="e.g. ab12cd34ef56" />
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="gate-status">Status</label>
|
<label for="gate-status">Status</label>
|
||||||
@@ -341,6 +644,16 @@
|
|||||||
<option value="disabled">Disabled</option>
|
<option value="disabled">Disabled</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem">
|
||||||
|
<div class="field">
|
||||||
|
<label for="gate-lat">Latitude <span style="color:var(--text-muted);font-weight:400">(optional)</span></label>
|
||||||
|
<input id="gate-lat" type="number" step="any" placeholder="e.g. 45.4654219" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="gate-lon">Longitude <span style="color:var(--text-muted);font-weight:400">(optional)</span></label>
|
||||||
|
<input id="gate-lon" type="number" step="any" placeholder="e.g. 9.1859347" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<p id="gate-error" class="error-msg hidden"></p>
|
<p id="gate-error" class="error-msg hidden"></p>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="button" id="gate-cancel" class="btn btn-ghost">Cancel</button>
|
<button type="button" id="gate-cancel" class="btn btn-ghost">Cancel</button>
|
||||||
@@ -402,6 +715,49 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── QR Code modal ─────────────────────────────────────────────────────── -->
|
||||||
|
<div id="qr-modal" class="modal-backdrop hidden">
|
||||||
|
<div class="modal" style="text-align:center;max-width:340px">
|
||||||
|
<h3 style="margin-bottom:.2rem">Keypass QR Code</h3>
|
||||||
|
<p id="qr-modal-desc" style="color:var(--text-muted);font-size:.85rem;margin-bottom:1.25rem"></p>
|
||||||
|
<div style="display:flex;justify-content:center;align-items:center;min-height:220px;background:var(--surface2);border-radius:8px;padding:1rem">
|
||||||
|
<img id="qr-img" src="" alt="QR Code" style="max-width:100%;border-radius:4px;display:block" />
|
||||||
|
</div>
|
||||||
|
<p style="color:var(--text-muted);font-size:.78rem;margin-top:.75rem">Scan to open the app and login automatically.</p>
|
||||||
|
<div class="modal-actions" style="justify-content:center;margin-top:1rem">
|
||||||
|
<button type="button" id="qr-close" class="btn btn-ghost">Close</button>
|
||||||
|
<a id="qr-download" download="keypass-qr.png" class="btn btn-primary" style="text-decoration:none">Download</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── TOTP setup modal ──────────────────────────────────────────────────── -->
|
||||||
|
<div id="totp-modal" class="modal-backdrop hidden">
|
||||||
|
<div class="modal" style="max-width:360px;text-align:center">
|
||||||
|
<h3>Set up Two-Factor Authentication</h3>
|
||||||
|
<div id="totp-step-scan">
|
||||||
|
<p style="color:var(--text-muted);font-size:.88rem;margin:.75rem 0 1rem">
|
||||||
|
Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.), then enter the 6-digit code to confirm.
|
||||||
|
</p>
|
||||||
|
<div style="display:flex;justify-content:center;background:#fff;padding:.75rem;border-radius:8px;margin-bottom:1rem">
|
||||||
|
<canvas id="totp-qr-canvas" style="max-width:100%;height:auto;display:block"></canvas>
|
||||||
|
</div>
|
||||||
|
<p id="totp-uri-fallback" style="font-size:.72rem;color:var(--text-muted);word-break:break-all;margin-bottom:1rem"></p>
|
||||||
|
<div class="field">
|
||||||
|
<label for="totp-confirm-code">Verification code</label>
|
||||||
|
<input id="totp-confirm-code" type="text" inputmode="numeric" pattern="[0-9]{6}"
|
||||||
|
maxlength="6" placeholder="000000"
|
||||||
|
style="font-family:monospace;font-size:1.4rem;letter-spacing:.25em;text-align:center" />
|
||||||
|
</div>
|
||||||
|
<p id="totp-error" class="error-msg hidden"></p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" id="totp-cancel" class="btn btn-ghost">Cancel</button>
|
||||||
|
<button type="button" id="totp-confirm-btn" class="btn btn-primary">Enable 2FA</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── Toast ───────────────────────────────────────────────────────────── -->
|
<!-- ── Toast ───────────────────────────────────────────────────────────── -->
|
||||||
<div id="toast" class="toast hidden" aria-live="assertive"></div>
|
<div id="toast" class="toast hidden" aria-live="assertive"></div>
|
||||||
|
|
||||||
|
|||||||
@@ -63,20 +63,42 @@ function showAdmin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Login ─────────────────────────────────────────────────────────────────────
|
// ── Login ─────────────────────────────────────────────────────────────────────
|
||||||
|
let _pendingCredentials = null; // { username, password } while OTP step is shown
|
||||||
|
|
||||||
document.getElementById("login-form").addEventListener("submit", async e => {
|
document.getElementById("login-form").addEventListener("submit", async e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const username = document.getElementById("admin-username").value.trim();
|
|
||||||
const password = document.getElementById("admin-password").value;
|
|
||||||
const errEl = document.getElementById("login-error");
|
const errEl = document.getElementById("login-error");
|
||||||
const btn = e.target.querySelector("button[type=submit]");
|
const btn = e.target.querySelector("button[type=submit]");
|
||||||
|
const otpField = document.getElementById("otp-field");
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
errEl.classList.add("hidden");
|
errEl.classList.add("hidden");
|
||||||
try {
|
try {
|
||||||
const data = await api("POST", "/api/auth/admin", { username, password });
|
const body = _pendingCredentials
|
||||||
saveToken(data.token);
|
? { ..._pendingCredentials, otp_code: document.getElementById("admin-otp").value.trim() }
|
||||||
showAdmin();
|
: { username: document.getElementById("admin-username").value.trim(),
|
||||||
} catch (e) {
|
password: document.getElementById("admin-password").value };
|
||||||
errEl.textContent = e.message;
|
const data = await api("POST", "/api/auth/admin", body);
|
||||||
|
if (data.otp_required) {
|
||||||
|
_pendingCredentials = {
|
||||||
|
username: document.getElementById("admin-username").value.trim(),
|
||||||
|
password: document.getElementById("admin-password").value,
|
||||||
|
};
|
||||||
|
otpField.classList.remove("hidden");
|
||||||
|
document.getElementById("admin-otp").focus();
|
||||||
|
errEl.textContent = "Enter the 6-digit code from your authenticator app.";
|
||||||
|
errEl.classList.remove("hidden");
|
||||||
|
} else {
|
||||||
|
_pendingCredentials = null;
|
||||||
|
otpField.classList.add("hidden");
|
||||||
|
document.getElementById("admin-otp").value = "";
|
||||||
|
saveToken(data.token);
|
||||||
|
showAdmin();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
_pendingCredentials = null;
|
||||||
|
otpField.classList.add("hidden");
|
||||||
|
document.getElementById("admin-otp").value = "";
|
||||||
|
errEl.textContent = err.message;
|
||||||
errEl.classList.remove("hidden");
|
errEl.classList.remove("hidden");
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
@@ -110,12 +132,20 @@ function showToast(msg, isError = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Keypasses ─────────────────────────────────────────────────────────────────
|
// ── Keypasses ─────────────────────────────────────────────────────────────────
|
||||||
|
const _DAY_SHORT = ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"];
|
||||||
|
function formatSchedule(sched) {
|
||||||
|
if (!sched) return null;
|
||||||
|
const parts = [];
|
||||||
|
if (sched.days && sched.days.length > 0) parts.push(sched.days.map(d => _DAY_SHORT[d]).join(" "));
|
||||||
|
if (sched.time_start && sched.time_end) parts.push(`${sched.time_start}–${sched.time_end}`);
|
||||||
|
return parts.length ? parts.join(" · ") : null;
|
||||||
|
}
|
||||||
async function loadKeypasses() {
|
async function loadKeypasses() {
|
||||||
const rows = await api("GET", "/api/admin/keypasses");
|
const rows = await api("GET", "/api/admin/keypasses");
|
||||||
const tbody = document.getElementById("keypasses-body");
|
const tbody = document.getElementById("keypasses-body");
|
||||||
tbody.innerHTML = "";
|
tbody.innerHTML = "";
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
tbody.innerHTML = '<tr><td colspan="5" style="color:var(--text-muted);text-align:center;padding:2rem">No keypasses yet</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="7" style="color:var(--text-muted);text-align:center;padding:2rem">No keypasses yet</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const kp of rows) {
|
for (const kp of rows) {
|
||||||
@@ -134,19 +164,27 @@ async function loadKeypasses() {
|
|||||||
? `<span style="white-space:nowrap">${fmtDate(kp.expires_at)}</span>`
|
? `<span style="white-space:nowrap">${fmtDate(kp.expires_at)}</span>`
|
||||||
: '<span style="color:var(--text-muted)">Never</span>';
|
: '<span style="color:var(--text-muted)">Never</span>';
|
||||||
|
|
||||||
|
const schedText = formatSchedule(kp.schedule);
|
||||||
|
const schedCell = schedText
|
||||||
|
? `<span style="font-size:.85em;white-space:nowrap">${esc(schedText)}</span>`
|
||||||
|
: '<span style="color:var(--text-muted)">Always</span>';
|
||||||
|
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
<td><code style="font-size:.95em;letter-spacing:.06em">${esc(kp.code)}</code></td>
|
<td><code style="font-size:.95em;letter-spacing:.06em">${esc(kp.code)}</code></td>
|
||||||
<td>${esc(kp.description)}</td>
|
<td>${esc(kp.description)}</td>
|
||||||
<td>${gatesCell}</td>
|
<td>${gatesCell}</td>
|
||||||
<td>${expiresCell}</td>
|
<td>${expiresCell}</td>
|
||||||
|
<td>${schedCell}</td>
|
||||||
<td>${badge}</td>
|
<td>${badge}</td>
|
||||||
<td style="text-align:right">
|
<td><div style="display:flex;gap:.5rem;justify-content:flex-end;white-space:nowrap">
|
||||||
${!kp.revoked ? `<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem"
|
${!kp.revoked && expMs >= now ? `<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem"
|
||||||
data-edit-kp='${JSON.stringify({id:kp.id, description:kp.description, expires_at:kp.expires_at, allowed_gate_ids:kp.allowed_gate_ids})}'>Edit</button>` : ""}
|
data-qr-kp-id="${kp.id}" data-qr-kp-desc="${esc(kp.description)}">QR</button>` : ""}
|
||||||
|
${!kp.revoked ? `<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem"
|
||||||
|
data-edit-kp='${JSON.stringify({id:kp.id, description:kp.description, expires_at:kp.expires_at, allowed_gate_ids:kp.allowed_gate_ids, schedule:kp.schedule})}'>Edit</button>` : ""}
|
||||||
${!kp.revoked && expMs >= now ? `<button class="btn btn-danger" style="font-size:.8rem;padding:.35rem .9rem"
|
${!kp.revoked && expMs >= now ? `<button class="btn btn-danger" style="font-size:.8rem;padding:.35rem .9rem"
|
||||||
data-kp-id="${kp.id}">Revoke</button>` : ""}
|
data-kp-id="${kp.id}">Revoke</button>` : ""}
|
||||||
</td>`;
|
</div></td>`;
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,16 +217,54 @@ async function loadKeypasses() {
|
|||||||
const lbl = document.createElement("label");
|
const lbl = document.createElement("label");
|
||||||
lbl.style.cssText = "display:flex;align-items:center;gap:.75rem;cursor:pointer;padding:.5rem .75rem;background:var(--surface2);border-radius:6px;border:1px solid var(--border);margin:0";
|
lbl.style.cssText = "display:flex;align-items:center;gap:.75rem;cursor:pointer;padding:.5rem .75rem;background:var(--surface2);border-radius:6px;border:1px solid var(--border);margin:0";
|
||||||
const checked = allowedIds && allowedIds.includes(g.id) ? "checked" : "";
|
const checked = allowedIds && allowedIds.includes(g.id) ? "checked" : "";
|
||||||
lbl.innerHTML = `<input type="checkbox" name="kp-edit-gate" value="${g.id}" ${checked} style="width:1rem;height:1rem;flex-shrink:0" /> <span>${esc(g.name)}</span> <span style="color:var(--text-muted);font-size:.85em;margin-left:auto">${g.gate_type === 'car' ? '🚘 Car' : '🚶 Pedestrian'}</span>`;
|
lbl.innerHTML = `<input type="checkbox" name="kp-edit-gate" value="${g.id}" ${checked} style="width:1rem;height:1rem;flex-shrink:0" /> <span>${esc(g.name)}</span> <span style="color:var(--text-muted);font-size:.85em;margin-left:auto">${g.gate_icon || ''}</span>`;
|
||||||
checksContainer.appendChild(lbl);
|
checksContainer.appendChild(lbl);
|
||||||
}
|
}
|
||||||
const allGatesCb = document.getElementById("kp-edit-all-gates");
|
const allGatesCb = document.getElementById("kp-edit-all-gates");
|
||||||
allGatesCb.checked = !allowedIds;
|
allGatesCb.checked = !allowedIds;
|
||||||
checksContainer.style.display = allowedIds ? "flex" : "none";
|
checksContainer.style.display = allowedIds ? "flex" : "none";
|
||||||
document.getElementById("kp-edit-error").classList.add("hidden");
|
document.getElementById("kp-edit-error").classList.add("hidden");
|
||||||
|
// Schedule
|
||||||
|
const sched = kp.schedule;
|
||||||
|
const noSchedCb = document.getElementById("kp-edit-no-schedule");
|
||||||
|
const schedWrap = document.getElementById("kp-edit-schedule-wrap");
|
||||||
|
noSchedCb.checked = !sched;
|
||||||
|
schedWrap.style.display = sched ? "flex" : "none";
|
||||||
|
document.querySelectorAll('input[name="kp-edit-day"]').forEach(cb => {
|
||||||
|
cb.checked = sched && sched.days ? sched.days.includes(parseInt(cb.value)) : false;
|
||||||
|
});
|
||||||
|
document.getElementById("kp-edit-time-start").value = (sched && sched.time_start) || "";
|
||||||
|
document.getElementById("kp-edit-time-end").value = (sched && sched.time_end) || "";
|
||||||
document.getElementById("kp-edit-modal").classList.remove("hidden");
|
document.getElementById("kp-edit-modal").classList.remove("hidden");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
tbody.querySelectorAll("[data-qr-kp-id]").forEach(btn => {
|
||||||
|
btn.addEventListener("click", async () => {
|
||||||
|
const id = btn.dataset.qrKpId;
|
||||||
|
const desc = btn.dataset.qrKpDesc;
|
||||||
|
document.getElementById("qr-modal-desc").textContent = desc;
|
||||||
|
const img = document.getElementById("qr-img");
|
||||||
|
const dl = document.getElementById("qr-download");
|
||||||
|
img.src = "";
|
||||||
|
dl.removeAttribute("href");
|
||||||
|
document.getElementById("qr-modal").classList.remove("hidden");
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/keypasses/${id}/qr`, {
|
||||||
|
headers: { "Authorization": `Bearer ${getToken()}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to load QR code");
|
||||||
|
const blob = await res.blob();
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
img.src = blobUrl;
|
||||||
|
dl.href = blobUrl;
|
||||||
|
dl.download = `keypass-${id}-qr.png`;
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message, true);
|
||||||
|
document.getElementById("qr-modal").classList.add("hidden");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
tbody.querySelectorAll("[data-kp-id]").forEach(btn => {
|
tbody.querySelectorAll("[data-kp-id]").forEach(btn => {
|
||||||
btn.addEventListener("click", async () => {
|
btn.addEventListener("click", async () => {
|
||||||
if (!confirm("Revoke this keypass?")) return;
|
if (!confirm("Revoke this keypass?")) return;
|
||||||
@@ -207,6 +283,12 @@ let _allGates = [];
|
|||||||
document.getElementById("btn-new-keypass").addEventListener("click", () => {
|
document.getElementById("btn-new-keypass").addEventListener("click", () => {
|
||||||
document.getElementById("kp-desc").value = "";
|
document.getElementById("kp-desc").value = "";
|
||||||
document.getElementById("kp-code").value = "";
|
document.getElementById("kp-code").value = "";
|
||||||
|
// Reset complexity — default to passphrase
|
||||||
|
document.getElementById("kp-charset").value = "passphrase";
|
||||||
|
document.getElementById("kp-length").value = 12;
|
||||||
|
document.getElementById("kp-length-val").textContent = "12";
|
||||||
|
document.getElementById("kp-length-wrap").style.display = "none";
|
||||||
|
document.getElementById("kp-autogen-options").style.display = "";
|
||||||
// Reset never-expires
|
// Reset never-expires
|
||||||
const neverCb = document.getElementById("kp-never-expires");
|
const neverCb = document.getElementById("kp-never-expires");
|
||||||
neverCb.checked = false;
|
neverCb.checked = false;
|
||||||
@@ -224,16 +306,33 @@ document.getElementById("btn-new-keypass").addEventListener("click", () => {
|
|||||||
for (const g of _allGates) {
|
for (const g of _allGates) {
|
||||||
const lbl = document.createElement("label");
|
const lbl = document.createElement("label");
|
||||||
lbl.style.cssText = "display:flex;align-items:center;gap:.75rem;cursor:pointer;padding:.5rem .75rem;background:var(--surface2);border-radius:6px;border:1px solid var(--border);margin:0";
|
lbl.style.cssText = "display:flex;align-items:center;gap:.75rem;cursor:pointer;padding:.5rem .75rem;background:var(--surface2);border-radius:6px;border:1px solid var(--border);margin:0";
|
||||||
lbl.innerHTML = `<input type="checkbox" name="kp-gate" value="${g.id}" style="width:1rem;height:1rem;flex-shrink:0" /> <span>${esc(g.name)}</span> <span style="color:var(--text-muted);font-size:.85em;margin-left:auto">${g.gate_type === 'car' ? '🚘 Car' : '🚶 Pedestrian'}</span>`;
|
lbl.innerHTML = `<input type="checkbox" name="kp-gate" value="${g.id}" style="width:1rem;height:1rem;flex-shrink:0" /> <span>${esc(g.name)}</span> <span style="color:var(--text-muted);font-size:.85em;margin-left:auto">${g.gate_icon || ''}</span>`;
|
||||||
checksContainer.appendChild(lbl);
|
checksContainer.appendChild(lbl);
|
||||||
}
|
}
|
||||||
// Reset All gates checkbox
|
// Reset All gates checkbox
|
||||||
document.getElementById("kp-all-gates").checked = true;
|
document.getElementById("kp-all-gates").checked = true;
|
||||||
checksContainer.style.display = "none";
|
checksContainer.style.display = "none";
|
||||||
|
// Reset schedule
|
||||||
|
document.getElementById("kp-no-schedule").checked = true;
|
||||||
|
document.getElementById("kp-schedule-wrap").style.display = "none";
|
||||||
|
document.querySelectorAll('input[name="kp-day"]').forEach(cb => cb.checked = false);
|
||||||
|
document.getElementById("kp-time-start").value = "";
|
||||||
|
document.getElementById("kp-time-end").value = "";
|
||||||
document.getElementById("kp-error").classList.add("hidden");
|
document.getElementById("kp-error").classList.add("hidden");
|
||||||
document.getElementById("keypass-modal").classList.remove("hidden");
|
document.getElementById("keypass-modal").classList.remove("hidden");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-generation options — hide when a manual code is typed, hide length for passphrase
|
||||||
|
document.getElementById("kp-code").addEventListener("input", e => {
|
||||||
|
document.getElementById("kp-autogen-options").style.display = e.target.value.trim() ? "none" : "";
|
||||||
|
});
|
||||||
|
document.getElementById("kp-charset").addEventListener("change", e => {
|
||||||
|
document.getElementById("kp-length-wrap").style.display = e.target.value === "passphrase" ? "none" : "";
|
||||||
|
});
|
||||||
|
document.getElementById("kp-length").addEventListener("input", e => {
|
||||||
|
document.getElementById("kp-length-val").textContent = e.target.value;
|
||||||
|
});
|
||||||
|
|
||||||
// Never expires toggle
|
// Never expires toggle
|
||||||
document.getElementById("kp-never-expires").addEventListener("change", e => {
|
document.getElementById("kp-never-expires").addEventListener("change", e => {
|
||||||
const kpExpInput = document.getElementById("kp-expires");
|
const kpExpInput = document.getElementById("kp-expires");
|
||||||
@@ -242,6 +341,11 @@ document.getElementById("kp-never-expires").addEventListener("change", e => {
|
|||||||
kpExpInput.style.opacity = e.target.checked ? ".4" : "";
|
kpExpInput.style.opacity = e.target.checked ? ".4" : "";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Schedule toggle
|
||||||
|
document.getElementById("kp-no-schedule").addEventListener("change", e => {
|
||||||
|
document.getElementById("kp-schedule-wrap").style.display = e.target.checked ? "none" : "flex";
|
||||||
|
});
|
||||||
|
|
||||||
// All gates toggle
|
// All gates toggle
|
||||||
document.getElementById("kp-all-gates").addEventListener("change", e => {
|
document.getElementById("kp-all-gates").addEventListener("change", e => {
|
||||||
const checksContainer = document.getElementById("kp-gate-checks");
|
const checksContainer = document.getElementById("kp-gate-checks");
|
||||||
@@ -269,6 +373,9 @@ document.getElementById("kp-edit-never").addEventListener("change", e => {
|
|||||||
expInput.disabled = e.target.checked;
|
expInput.disabled = e.target.checked;
|
||||||
expInput.style.opacity = e.target.checked ? ".4" : "";
|
expInput.style.opacity = e.target.checked ? ".4" : "";
|
||||||
});
|
});
|
||||||
|
document.getElementById("kp-edit-no-schedule").addEventListener("change", e => {
|
||||||
|
document.getElementById("kp-edit-schedule-wrap").style.display = e.target.checked ? "none" : "flex";
|
||||||
|
});
|
||||||
document.getElementById("kp-edit-all-gates").addEventListener("change", e => {
|
document.getElementById("kp-edit-all-gates").addEventListener("change", e => {
|
||||||
const checksContainer = document.getElementById("kp-edit-gate-checks");
|
const checksContainer = document.getElementById("kp-edit-gate-checks");
|
||||||
if (e.target.checked) {
|
if (e.target.checked) {
|
||||||
@@ -285,6 +392,13 @@ document.getElementById("kp-edit-gate-checks").addEventListener("change", () =>
|
|||||||
document.getElementById("kp-edit-cancel").addEventListener("click", () => {
|
document.getElementById("kp-edit-cancel").addEventListener("click", () => {
|
||||||
document.getElementById("kp-edit-modal").classList.add("hidden");
|
document.getElementById("kp-edit-modal").classList.add("hidden");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById("qr-close").addEventListener("click", () => {
|
||||||
|
const img = document.getElementById("qr-img");
|
||||||
|
if (img.src.startsWith("blob:")) URL.revokeObjectURL(img.src);
|
||||||
|
img.src = "";
|
||||||
|
document.getElementById("qr-modal").classList.add("hidden");
|
||||||
|
});
|
||||||
document.getElementById("kp-edit-form").addEventListener("submit", async e => {
|
document.getElementById("kp-edit-form").addEventListener("submit", async e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const id = document.getElementById("kp-edit-id").value;
|
const id = document.getElementById("kp-edit-id").value;
|
||||||
@@ -293,10 +407,22 @@ document.getElementById("kp-edit-form").addEventListener("submit", async e => {
|
|||||||
const expires_at = never ? null : new Date(document.getElementById("kp-edit-expires").value).toISOString();
|
const expires_at = never ? null : new Date(document.getElementById("kp-edit-expires").value).toISOString();
|
||||||
const allGates = document.getElementById("kp-edit-all-gates").checked;
|
const allGates = document.getElementById("kp-edit-all-gates").checked;
|
||||||
const gate_ids = allGates ? [] : Array.from(document.querySelectorAll('input[name="kp-edit-gate"]:checked')).map(cb => parseInt(cb.value));
|
const gate_ids = allGates ? [] : Array.from(document.querySelectorAll('input[name="kp-edit-gate"]:checked')).map(cb => parseInt(cb.value));
|
||||||
|
const noEditSched = document.getElementById("kp-edit-no-schedule").checked;
|
||||||
|
let schedule = null;
|
||||||
|
if (!noEditSched) {
|
||||||
|
const days = Array.from(document.querySelectorAll('input[name="kp-edit-day"]:checked')).map(cb => parseInt(cb.value));
|
||||||
|
const time_start = document.getElementById("kp-edit-time-start").value || undefined;
|
||||||
|
const time_end = document.getElementById("kp-edit-time-end").value || undefined;
|
||||||
|
schedule = {};
|
||||||
|
if (days.length) schedule.days = days;
|
||||||
|
if (time_start) schedule.time_start = time_start;
|
||||||
|
if (time_end) schedule.time_end = time_end;
|
||||||
|
if (!Object.keys(schedule).length) schedule = null;
|
||||||
|
}
|
||||||
const errEl = document.getElementById("kp-edit-error");
|
const errEl = document.getElementById("kp-edit-error");
|
||||||
errEl.classList.add("hidden");
|
errEl.classList.add("hidden");
|
||||||
try {
|
try {
|
||||||
await api("PATCH", `/api/admin/keypasses/${id}`, { description, expires_at, gate_ids });
|
await api("PATCH", `/api/admin/keypasses/${id}`, { description, expires_at, gate_ids, schedule });
|
||||||
document.getElementById("kp-edit-modal").classList.add("hidden");
|
document.getElementById("kp-edit-modal").classList.add("hidden");
|
||||||
showToast("Keypass updated");
|
showToast("Keypass updated");
|
||||||
loadKeypasses();
|
loadKeypasses();
|
||||||
@@ -309,14 +435,28 @@ document.getElementById("keypass-form").addEventListener("submit", async e => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const desc = document.getElementById("kp-desc").value.trim();
|
const desc = document.getElementById("kp-desc").value.trim();
|
||||||
const code = document.getElementById("kp-code").value.trim() || null;
|
const code = document.getElementById("kp-code").value.trim() || null;
|
||||||
|
const charset = document.getElementById("kp-charset").value;
|
||||||
|
const length = parseInt(document.getElementById("kp-length").value, 10);
|
||||||
const neverExpires = document.getElementById("kp-never-expires").checked;
|
const neverExpires = document.getElementById("kp-never-expires").checked;
|
||||||
const expires_at = neverExpires ? null : new Date(document.getElementById("kp-expires").value).toISOString();
|
const expires_at = neverExpires ? null : new Date(document.getElementById("kp-expires").value).toISOString();
|
||||||
const allGates = document.getElementById("kp-all-gates").checked;
|
const allGates = document.getElementById("kp-all-gates").checked;
|
||||||
const gate_ids = allGates ? [] : Array.from(document.querySelectorAll('input[name="kp-gate"]:checked')).map(cb => parseInt(cb.value));
|
const gate_ids = allGates ? [] : Array.from(document.querySelectorAll('input[name="kp-gate"]:checked')).map(cb => parseInt(cb.value));
|
||||||
|
const noSched = document.getElementById("kp-no-schedule").checked;
|
||||||
|
let schedule = null;
|
||||||
|
if (!noSched) {
|
||||||
|
const days = Array.from(document.querySelectorAll('input[name="kp-day"]:checked')).map(cb => parseInt(cb.value));
|
||||||
|
const time_start = document.getElementById("kp-time-start").value || undefined;
|
||||||
|
const time_end = document.getElementById("kp-time-end").value || undefined;
|
||||||
|
schedule = {};
|
||||||
|
if (days.length) schedule.days = days;
|
||||||
|
if (time_start) schedule.time_start = time_start;
|
||||||
|
if (time_end) schedule.time_end = time_end;
|
||||||
|
if (!Object.keys(schedule).length) schedule = null;
|
||||||
|
}
|
||||||
const errEl = document.getElementById("kp-error");
|
const errEl = document.getElementById("kp-error");
|
||||||
errEl.classList.add("hidden");
|
errEl.classList.add("hidden");
|
||||||
try {
|
try {
|
||||||
await api("POST", "/api/admin/keypasses", { description: desc, expires_at, gate_ids, code });
|
await api("POST", "/api/admin/keypasses", { description: desc, expires_at, gate_ids, code, charset, length, schedule });
|
||||||
document.getElementById("keypass-modal").classList.add("hidden");
|
document.getElementById("keypass-modal").classList.add("hidden");
|
||||||
showToast("Keypass created");
|
showToast("Keypass created");
|
||||||
loadKeypasses();
|
loadKeypasses();
|
||||||
@@ -330,6 +470,17 @@ document.getElementById("keypass-form").addEventListener("submit", async e => {
|
|||||||
async function loadGates() {
|
async function loadGates() {
|
||||||
const rows = await api("GET", "/api/admin/gates");
|
const rows = await api("GET", "/api/admin/gates");
|
||||||
_allGates = rows; // cache for keypass modal
|
_allGates = rows; // cache for keypass modal
|
||||||
|
// Populate the stats gate filter dropdown
|
||||||
|
const filterGate = document.getElementById("filter-gate");
|
||||||
|
const prevGateVal = filterGate.value;
|
||||||
|
filterGate.innerHTML = '<option value="">Any</option>';
|
||||||
|
for (const g of rows) {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = g.id;
|
||||||
|
opt.textContent = g.name;
|
||||||
|
filterGate.appendChild(opt);
|
||||||
|
}
|
||||||
|
filterGate.value = prevGateVal; // restore selection if still valid
|
||||||
const isAdmin = _tokenPayload().scope === "admin";
|
const isAdmin = _tokenPayload().scope === "admin";
|
||||||
const tbody = document.getElementById("gates-body");
|
const tbody = document.getElementById("gates-body");
|
||||||
tbody.innerHTML = "";
|
tbody.innerHTML = "";
|
||||||
@@ -341,21 +492,25 @@ async function loadGates() {
|
|||||||
const badge = g.status === "enabled"
|
const badge = g.status === "enabled"
|
||||||
? '<span class="badge badge-green">Enabled</span>'
|
? '<span class="badge badge-green">Enabled</span>'
|
||||||
: '<span class="badge badge-muted">Disabled</span>';
|
: '<span class="badge badge-muted">Disabled</span>';
|
||||||
|
const providerLabel = g.api_provider === 'shelly' ? 'Shelly' : 'AVConnect';
|
||||||
|
const deviceId = g.api_provider === 'shelly' ? (g.shelly_device_id || '') : (g.avconnect_macro_id || '');
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
<td>${g.id}</td>
|
<td>${g.id}</td>
|
||||||
<td>${esc(g.name)}</td>
|
<td>${esc(g.name)}</td>
|
||||||
<td>${g.gate_type === "car" ? "🚘 Car" : "🚶 Pedestrian"}</td>
|
<td>${g.group_name ? esc(g.group_name) : '<span style="color:var(--text-muted)">\u2014</span>'}</td>
|
||||||
<td><code style="font-size:.85em">${esc(g.avconnect_macro_id)}</code></td>
|
<td>${g.gate_icon || ''}</td>
|
||||||
|
<td><span style="font-size:.85em">${esc(providerLabel)}</span></td>
|
||||||
|
<td><code style="font-size:.85em">${esc(deviceId)}</code></td>
|
||||||
<td>${badge}</td>
|
<td>${badge}</td>
|
||||||
<td style="text-align:right;white-space:nowrap;display:flex;gap:.5rem;justify-content:flex-end">
|
<td><div style="text-align:right;white-space:nowrap;display:flex;gap:.5rem;justify-content:flex-end">
|
||||||
${g.status === 'enabled' ? `<button class="btn btn-primary" style="font-size:.8rem;padding:.35rem .9rem"
|
${g.status === 'enabled' ? `<button class="btn btn-primary" style="font-size:.8rem;padding:.35rem .9rem"
|
||||||
data-open-id="${g.id}">Open</button>` : ''}
|
data-open-id="${g.id}">Open</button>` : ''}
|
||||||
${isAdmin ? `<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem"
|
${isAdmin ? `<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem"
|
||||||
data-edit-id="${g.id}" data-gate='${JSON.stringify(g)}'>Edit</button>
|
data-edit-id="${g.id}" data-gate='${JSON.stringify(g)}'>Edit</button>
|
||||||
<button class="btn btn-danger" style="font-size:.8rem;padding:.35rem .9rem"
|
<button class="btn btn-danger" style="font-size:.8rem;padding:.35rem .9rem"
|
||||||
data-del-id="${g.id}">Delete</button>` : ''}
|
data-del-id="${g.id}">Delete</button>` : ''}
|
||||||
</td>`;
|
</div></td>`;
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,29 +541,95 @@ async function loadGates() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _setGateIcon(icon) {
|
||||||
|
document.getElementById("gate-icon-input").value = icon;
|
||||||
|
document.getElementById("gate-icon-preview").textContent = icon;
|
||||||
|
document.querySelectorAll("#gate-icon-grid .icon-opt").forEach(btn => {
|
||||||
|
btn.classList.toggle("selected", btn.dataset.icon === icon);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icon grid clicks + manual input sync
|
||||||
|
document.getElementById("gate-icon-grid").addEventListener("click", e => {
|
||||||
|
const btn = e.target.closest(".icon-opt");
|
||||||
|
if (btn) _setGateIcon(btn.dataset.icon);
|
||||||
|
});
|
||||||
|
document.getElementById("gate-icon-input").addEventListener("input", e => {
|
||||||
|
const val = [...e.target.value].slice(0, 2).join(""); // keep at most one composed char
|
||||||
|
if (val) {
|
||||||
|
document.getElementById("gate-icon-preview").textContent = val;
|
||||||
|
document.querySelectorAll("#gate-icon-grid .icon-opt").forEach(btn => {
|
||||||
|
btn.classList.toggle("selected", btn.dataset.icon === val);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function openGateModal(gate = null) {
|
function openGateModal(gate = null) {
|
||||||
document.getElementById("gate-modal-title").textContent = gate ? "Edit Gate" : "Add Gate";
|
document.getElementById("gate-modal-title").textContent = gate ? "Edit Gate" : "Add Gate";
|
||||||
document.getElementById("gate-edit-id").value = gate ? gate.id : "";
|
document.getElementById("gate-edit-id").value = gate ? gate.id : "";
|
||||||
document.getElementById("gate-name").value = gate ? gate.name : "";
|
document.getElementById("gate-name").value = gate ? gate.name : "";
|
||||||
document.getElementById("gate-type").value = gate ? gate.gate_type : "car";
|
document.getElementById("gate-group-name").value = gate ? (gate.group_name || "") : "";
|
||||||
document.getElementById("gate-avconnect-macro-id").value = gate ? gate.avconnect_macro_id : "";
|
_setGateIcon(gate ? (gate.gate_icon || "🚪") : "🚪");
|
||||||
|
const provider = gate ? (gate.api_provider || "avconnect") : "avconnect";
|
||||||
|
document.getElementById("gate-api-provider").value = provider;
|
||||||
|
document.getElementById("gate-avconnect-macro-id").value = gate ? (gate.avconnect_macro_id || "") : "";
|
||||||
|
document.getElementById("gate-shelly-device-id").value = gate ? (gate.shelly_device_id || "") : "";
|
||||||
|
_updateGateProviderFields(provider);
|
||||||
document.getElementById("gate-status").value = gate ? gate.status : "enabled";
|
document.getElementById("gate-status").value = gate ? gate.status : "enabled";
|
||||||
|
document.getElementById("gate-lat").value = (gate && gate.lat != null) ? gate.lat : "";
|
||||||
|
document.getElementById("gate-lon").value = (gate && gate.lon != null) ? gate.lon : "";
|
||||||
document.getElementById("gate-error").classList.add("hidden");
|
document.getElementById("gate-error").classList.add("hidden");
|
||||||
|
// Populate group suggestions from existing gates
|
||||||
|
const dl = document.getElementById("gate-group-list");
|
||||||
|
dl.innerHTML = "";
|
||||||
|
const groups = [...new Set((_allGates || []).map(g => g.group_name).filter(Boolean))].sort();
|
||||||
|
for (const g of groups) {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = g;
|
||||||
|
dl.appendChild(opt);
|
||||||
|
}
|
||||||
document.getElementById("gate-modal").classList.remove("hidden");
|
document.getElementById("gate-modal").classList.remove("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _updateGateProviderFields(provider) {
|
||||||
|
const avField = document.getElementById("gate-avconnect-field");
|
||||||
|
const shellyField = document.getElementById("gate-shelly-field");
|
||||||
|
const avInput = document.getElementById("gate-avconnect-macro-id");
|
||||||
|
const shellyInput = document.getElementById("gate-shelly-device-id");
|
||||||
|
if (provider === "shelly") {
|
||||||
|
avField.style.display = "none";
|
||||||
|
shellyField.style.display = "";
|
||||||
|
avInput.required = false;
|
||||||
|
shellyInput.required = true;
|
||||||
|
} else {
|
||||||
|
avField.style.display = "";
|
||||||
|
shellyField.style.display = "none";
|
||||||
|
avInput.required = true;
|
||||||
|
shellyInput.required = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById("btn-new-gate").addEventListener("click", () => openGateModal());
|
document.getElementById("btn-new-gate").addEventListener("click", () => openGateModal());
|
||||||
document.getElementById("gate-cancel").addEventListener("click", () => {
|
document.getElementById("gate-cancel").addEventListener("click", () => {
|
||||||
document.getElementById("gate-modal").classList.add("hidden");
|
document.getElementById("gate-modal").classList.add("hidden");
|
||||||
});
|
});
|
||||||
|
document.getElementById("gate-api-provider").addEventListener("change", e => {
|
||||||
|
_updateGateProviderFields(e.target.value);
|
||||||
|
});
|
||||||
document.getElementById("gate-form").addEventListener("submit", async e => {
|
document.getElementById("gate-form").addEventListener("submit", async e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const editId = document.getElementById("gate-edit-id").value;
|
const editId = document.getElementById("gate-edit-id").value;
|
||||||
|
const provider = document.getElementById("gate-api-provider").value;
|
||||||
const payload = {
|
const payload = {
|
||||||
name: document.getElementById("gate-name").value.trim(),
|
name: document.getElementById("gate-name").value.trim(),
|
||||||
gate_type: document.getElementById("gate-type").value,
|
gate_icon: document.getElementById("gate-icon-input").value.trim() || "\uD83D\uDEAA",
|
||||||
avconnect_macro_id: document.getElementById("gate-avconnect-macro-id").value.trim(),
|
api_provider: provider,
|
||||||
|
avconnect_macro_id: provider === "avconnect" ? document.getElementById("gate-avconnect-macro-id").value.trim() : null,
|
||||||
|
shelly_device_id: provider === "shelly" ? document.getElementById("gate-shelly-device-id").value.trim() : null,
|
||||||
status: document.getElementById("gate-status").value,
|
status: document.getElementById("gate-status").value,
|
||||||
|
group_name: document.getElementById("gate-group-name").value.trim() || null,
|
||||||
|
lat: document.getElementById("gate-lat").value !== "" ? parseFloat(document.getElementById("gate-lat").value) : null,
|
||||||
|
lon: document.getElementById("gate-lon").value !== "" ? parseFloat(document.getElementById("gate-lon").value) : null,
|
||||||
};
|
};
|
||||||
const errEl = document.getElementById("gate-error");
|
const errEl = document.getElementById("gate-error");
|
||||||
errEl.classList.add("hidden");
|
errEl.classList.add("hidden");
|
||||||
@@ -430,13 +651,33 @@ document.getElementById("gate-form").addEventListener("submit", async e => {
|
|||||||
// ── Credentials ───────────────────────────────────────────────────────────────
|
// ── Credentials ───────────────────────────────────────────────────────────────
|
||||||
async function loadCredentials() {
|
async function loadCredentials() {
|
||||||
try {
|
try {
|
||||||
const list = await api("GET", "/api/admin/credentials");
|
const list = await api("GET", "/api/admin/credentials/avconnect");
|
||||||
if (list.length) {
|
if (list.length) {
|
||||||
document.getElementById("cred-username").value = list[0].username;
|
document.getElementById("cred-username").value = list[0].username;
|
||||||
}
|
}
|
||||||
} catch { /* no creds yet */ }
|
} catch { /* no creds yet */ }
|
||||||
|
try {
|
||||||
|
const shelly = await api("GET", "/api/admin/credentials/shelly");
|
||||||
|
if (shelly) {
|
||||||
|
document.getElementById("shelly-server-uri").value = shelly.server_uri;
|
||||||
|
}
|
||||||
|
} catch { /* no shelly creds yet */ }
|
||||||
|
try {
|
||||||
|
const { enabled } = await api("GET", "/api/admin/credentials/mock");
|
||||||
|
document.getElementById("mock-toggle").checked = enabled;
|
||||||
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.getElementById("mock-toggle").addEventListener("change", async e => {
|
||||||
|
try {
|
||||||
|
await api("PUT", "/api/admin/credentials/mock", { enabled: e.target.checked });
|
||||||
|
showToast(e.target.checked ? "Mock mode enabled" : "Mock mode disabled");
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, true);
|
||||||
|
e.target.checked = !e.target.checked; // revert
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById("credentials-form").addEventListener("submit", async e => {
|
document.getElementById("credentials-form").addEventListener("submit", async e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const username = document.getElementById("cred-username").value.trim();
|
const username = document.getElementById("cred-username").value.trim();
|
||||||
@@ -449,9 +690,30 @@ document.getElementById("credentials-form").addEventListener("submit", async e =
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await api("PUT", "/api/admin/credentials", { username, password });
|
await api("PUT", "/api/admin/credentials/avconnect", { username, password });
|
||||||
document.getElementById("cred-password").value = "";
|
document.getElementById("cred-password").value = "";
|
||||||
showToast("Credentials saved");
|
showToast("AVConnect credentials saved");
|
||||||
|
} catch (e) {
|
||||||
|
errEl.textContent = e.message;
|
||||||
|
errEl.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("shelly-credentials-form").addEventListener("submit", async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const server_uri = document.getElementById("shelly-server-uri").value.trim();
|
||||||
|
const auth_key = document.getElementById("shelly-auth-key").value;
|
||||||
|
const errEl = document.getElementById("shelly-cred-error");
|
||||||
|
errEl.classList.add("hidden");
|
||||||
|
if (!auth_key) {
|
||||||
|
errEl.textContent = "Auth key is required.";
|
||||||
|
errEl.classList.remove("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api("PUT", "/api/admin/credentials/shelly", { server_uri, auth_key });
|
||||||
|
document.getElementById("shelly-auth-key").value = "";
|
||||||
|
showToast("Shelly Cloud credentials saved");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errEl.textContent = e.message;
|
errEl.textContent = e.message;
|
||||||
errEl.classList.remove("hidden");
|
errEl.classList.remove("hidden");
|
||||||
@@ -459,16 +721,47 @@ document.getElementById("credentials-form").addEventListener("submit", async e =
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Statistics ───────────────────────────────────────────────────────────────
|
// ── Statistics ───────────────────────────────────────────────────────────────
|
||||||
|
const STATS_PAGE_SIZE = 50;
|
||||||
|
let _statsPage = 1;
|
||||||
|
let _statsTotal = 0;
|
||||||
|
|
||||||
|
function _buildStatsParams() {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const keypass = document.getElementById("filter-keypass").value.trim();
|
||||||
|
if (keypass) params.set("keypass_code", keypass.toUpperCase());
|
||||||
|
const gate = document.getElementById("filter-gate").value;
|
||||||
|
if (gate) params.set("gate_id", gate);
|
||||||
|
const success = document.getElementById("filter-success").value;
|
||||||
|
if (success !== "") params.set("success", success);
|
||||||
|
const from = document.getElementById("filter-from").value;
|
||||||
|
if (from) params.set("date_from", new Date(from).toISOString());
|
||||||
|
const to = document.getElementById("filter-to").value;
|
||||||
|
if (to) params.set("date_to", new Date(to).toISOString());
|
||||||
|
params.set("page", _statsPage);
|
||||||
|
params.set("page_size", STATS_PAGE_SIZE);
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadStats() {
|
async function loadStats() {
|
||||||
try {
|
try {
|
||||||
const rows = await api("GET", "/api/admin/stats");
|
const data = await api("GET", `/api/admin/stats?${_buildStatsParams()}`);
|
||||||
|
_statsTotal = data.total;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(_statsTotal / STATS_PAGE_SIZE));
|
||||||
|
|
||||||
|
document.getElementById("stats-total-label").textContent =
|
||||||
|
`${_statsTotal} record${_statsTotal !== 1 ? "s" : ""}`;
|
||||||
|
document.getElementById("stats-page-label").textContent =
|
||||||
|
`Page ${_statsPage} of ${totalPages}`;
|
||||||
|
document.getElementById("btn-stats-prev").disabled = _statsPage <= 1;
|
||||||
|
document.getElementById("btn-stats-next").disabled = _statsPage >= totalPages;
|
||||||
|
|
||||||
const tbody = document.getElementById("stats-body");
|
const tbody = document.getElementById("stats-body");
|
||||||
tbody.innerHTML = "";
|
tbody.innerHTML = "";
|
||||||
if (!rows.length) {
|
if (!data.items.length) {
|
||||||
tbody.innerHTML = '<tr><td colspan="6" style="color:var(--text-muted);text-align:center;padding:2rem">No access logs yet</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="6" style="color:var(--text-muted);text-align:center;padding:2rem">No records match the current filters</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const r of rows) {
|
for (const r of data.items) {
|
||||||
const badge = r.success
|
const badge = r.success
|
||||||
? '<span class="badge badge-green">OK</span>'
|
? '<span class="badge badge-green">OK</span>'
|
||||||
: `<span class="badge badge-red" title="${esc(r.error || '')}">Fail</span>`;
|
: `<span class="badge badge-red" title="${esc(r.error || '')}">Fail</span>`;
|
||||||
@@ -485,9 +778,24 @@ async function loadStats() {
|
|||||||
} catch (e) { showToast(e.message, true); }
|
} catch (e) { showToast(e.message, true); }
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("btn-refresh-stats").addEventListener("click", loadStats);
|
document.getElementById("btn-refresh-stats").addEventListener("click", () => { _statsPage = 1; loadStats(); });
|
||||||
|
document.getElementById("btn-stats-filter").addEventListener("click", () => { _statsPage = 1; loadStats(); });
|
||||||
document.getElementById("btn-refresh-stats").addEventListener("click", loadStats);
|
document.getElementById("btn-stats-reset").addEventListener("click", () => {
|
||||||
|
document.getElementById("filter-keypass").value = "";
|
||||||
|
document.getElementById("filter-gate").value = "";
|
||||||
|
document.getElementById("filter-success").value = "";
|
||||||
|
document.getElementById("filter-from").value = "";
|
||||||
|
document.getElementById("filter-to").value = "";
|
||||||
|
_statsPage = 1;
|
||||||
|
loadStats();
|
||||||
|
});
|
||||||
|
document.getElementById("btn-stats-prev").addEventListener("click", () => {
|
||||||
|
if (_statsPage > 1) { _statsPage--; loadStats(); }
|
||||||
|
});
|
||||||
|
document.getElementById("btn-stats-next").addEventListener("click", () => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(_statsTotal / STATS_PAGE_SIZE));
|
||||||
|
if (_statsPage < totalPages) { _statsPage++; loadStats(); }
|
||||||
|
});
|
||||||
|
|
||||||
// ── Admin users ───────────────────────────────────────────────────────────────
|
// ── Admin users ───────────────────────────────────────────────────────────────
|
||||||
async function loadAdmins() {
|
async function loadAdmins() {
|
||||||
@@ -503,15 +811,39 @@ async function loadAdmins() {
|
|||||||
const roleBadge = u.role === "admin"
|
const roleBadge = u.role === "admin"
|
||||||
? '<span class="badge badge-green" style="font-size:.75em">admin</span>'
|
? '<span class="badge badge-green" style="font-size:.75em">admin</span>'
|
||||||
: '<span class="badge badge-muted" style="font-size:.75em">manager</span>';
|
: '<span class="badge badge-muted" style="font-size:.75em">manager</span>';
|
||||||
|
const totpBadge = u.totp_enabled
|
||||||
|
? '<span class="badge badge-green" style="font-size:.75em;background:var(--accent-warn,#b45309);color:#fff" title="2FA enabled">2FA ✓</span>'
|
||||||
|
: '';
|
||||||
|
const is_me = u.username === me;
|
||||||
|
const totpBtn = is_me
|
||||||
|
? `<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem" data-totp="${esc(u.username)}" data-totp-enabled="${u.totp_enabled}">${u.totp_enabled ? "Disable 2FA" : "Enable 2FA"}</button>`
|
||||||
|
: "";
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
<td>${esc(u.username)}${u.username === me ? ' <span class="badge badge-green" style="font-size:.75em">you</span>' : ""} ${roleBadge}</td>
|
<td><div style="display:flex;align-items:center;gap:.4rem;flex-wrap:nowrap">${esc(u.username)}${is_me ? '<span class="badge badge-green" style="font-size:.75em">you</span>' : ""} ${roleBadge} ${totpBadge}</div></td>
|
||||||
<td style="text-align:right;display:flex;gap:.5rem;justify-content:flex-end">
|
<td><div style="text-align:right;display:flex;gap:.5rem;justify-content:flex-end">
|
||||||
|
${totpBtn}
|
||||||
<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem" data-chpw="${esc(u.username)}">Change password</button>
|
<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem" data-chpw="${esc(u.username)}">Change password</button>
|
||||||
${u.username !== me ? `<button class="btn btn-danger" style="font-size:.8rem;padding:.35rem .9rem" data-del-admin="${esc(u.username)}">Delete</button>` : ""}
|
${!is_me ? `<button class="btn btn-danger" style="font-size:.8rem;padding:.35rem .9rem" data-del-admin="${esc(u.username)}">Delete</button>` : ""}
|
||||||
</td>`;
|
</div></td>`;
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
}
|
}
|
||||||
|
tbody.querySelectorAll("[data-totp]").forEach(btn => {
|
||||||
|
btn.addEventListener("click", async () => {
|
||||||
|
const username = btn.dataset.totp;
|
||||||
|
const enabled = btn.dataset.totpEnabled === "true";
|
||||||
|
if (enabled) {
|
||||||
|
if (!confirm("Disable two-factor authentication for your account?")) return;
|
||||||
|
try {
|
||||||
|
await api("DELETE", `/api/admin/admins/${encodeURIComponent(username)}/totp`);
|
||||||
|
showToast("2FA disabled");
|
||||||
|
loadAdmins();
|
||||||
|
} catch (e) { showToast(e.message, true); }
|
||||||
|
} else {
|
||||||
|
openTotpSetup(username);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
tbody.querySelectorAll("[data-chpw]").forEach(btn => {
|
tbody.querySelectorAll("[data-chpw]").forEach(btn => {
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
document.getElementById("chpw-username").value = btn.dataset.chpw;
|
document.getElementById("chpw-username").value = btn.dataset.chpw;
|
||||||
@@ -533,6 +865,57 @@ async function loadAdmins() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── TOTP setup modal ──────────────────────────────────────────────────────────
|
||||||
|
let _totpUsername = null;
|
||||||
|
|
||||||
|
async function openTotpSetup(username) {
|
||||||
|
_totpUsername = username;
|
||||||
|
document.getElementById("totp-confirm-code").value = "";
|
||||||
|
document.getElementById("totp-error").classList.add("hidden");
|
||||||
|
document.getElementById("totp-modal").classList.remove("hidden");
|
||||||
|
try {
|
||||||
|
const data = await api("POST", `/api/admin/admins/${encodeURIComponent(username)}/totp/setup`);
|
||||||
|
// Render QR from base64 PNG returned by the server
|
||||||
|
const canvas = document.getElementById("totp-qr-canvas");
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
canvas.width = img.width;
|
||||||
|
canvas.height = img.height;
|
||||||
|
canvas.style.width = Math.min(img.width, 220) + "px";
|
||||||
|
canvas.style.height = "auto";
|
||||||
|
canvas.getContext("2d").drawImage(img, 0, 0);
|
||||||
|
};
|
||||||
|
img.src = "data:image/png;base64," + data.qr_image_b64;
|
||||||
|
document.getElementById("totp-uri-fallback").textContent = data.provisioning_uri;
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById("totp-error").textContent = e.message;
|
||||||
|
document.getElementById("totp-error").classList.remove("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("totp-cancel").addEventListener("click", () => {
|
||||||
|
document.getElementById("totp-modal").classList.add("hidden");
|
||||||
|
_totpUsername = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("totp-confirm-btn").addEventListener("click", async () => {
|
||||||
|
if (!_totpUsername) return;
|
||||||
|
const code = document.getElementById("totp-confirm-code").value.trim();
|
||||||
|
const errEl = document.getElementById("totp-error");
|
||||||
|
errEl.classList.add("hidden");
|
||||||
|
if (!code) { errEl.textContent = "Enter the 6-digit code from your app."; errEl.classList.remove("hidden"); return; }
|
||||||
|
try {
|
||||||
|
await api("POST", `/api/admin/admins/${encodeURIComponent(_totpUsername)}/totp/enable`, { otp_code: code });
|
||||||
|
document.getElementById("totp-modal").classList.add("hidden");
|
||||||
|
_totpUsername = null;
|
||||||
|
showToast("Two-factor authentication enabled");
|
||||||
|
loadAdmins();
|
||||||
|
} catch (e) {
|
||||||
|
errEl.textContent = e.message;
|
||||||
|
errEl.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById("btn-new-admin").addEventListener("click", () => {
|
document.getElementById("btn-new-admin").addEventListener("click", () => {
|
||||||
document.getElementById("admin-new-username").value = "";
|
document.getElementById("admin-new-username").value = "";
|
||||||
document.getElementById("admin-new-password").value = "";
|
document.getElementById("admin-new-password").value = "";
|
||||||
@@ -587,6 +970,148 @@ document.getElementById("chpw-form").addEventListener("submit", async e => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Telegram / Notifications ──────────────────────────────────────────────────
|
||||||
|
async function loadTelegram() {
|
||||||
|
try {
|
||||||
|
const cfg = await api("GET", "/api/admin/telegram");
|
||||||
|
document.getElementById("tg-chat-id").value = cfg.chat_id || "";
|
||||||
|
document.getElementById("tg-enabled").checked = cfg.enabled;
|
||||||
|
document.getElementById("tg-status").textContent = cfg.configured
|
||||||
|
? "Bot token is saved. Leave the token field empty to keep it."
|
||||||
|
: "Not configured yet. Enter a bot token to get started.";
|
||||||
|
} catch { /* non-admin: tab hidden anyway */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("telegram-form").addEventListener("submit", async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const token = document.getElementById("tg-token").value.trim() || null;
|
||||||
|
const chat_id = document.getElementById("tg-chat-id").value.trim();
|
||||||
|
const enabled = document.getElementById("tg-enabled").checked;
|
||||||
|
const errEl = document.getElementById("tg-error");
|
||||||
|
errEl.classList.add("hidden");
|
||||||
|
try {
|
||||||
|
await api("PUT", "/api/admin/telegram", { bot_token: token, chat_id, enabled });
|
||||||
|
document.getElementById("tg-token").value = "";
|
||||||
|
showToast("Telegram settings saved");
|
||||||
|
loadTelegram();
|
||||||
|
} catch (e) {
|
||||||
|
errEl.textContent = e.message;
|
||||||
|
errEl.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("btn-tg-test").addEventListener("click", async () => {
|
||||||
|
const btn = document.getElementById("btn-tg-test");
|
||||||
|
const errEl = document.getElementById("tg-error");
|
||||||
|
btn.disabled = true;
|
||||||
|
errEl.classList.add("hidden");
|
||||||
|
try {
|
||||||
|
await api("POST", "/api/admin/telegram/test");
|
||||||
|
showToast("Test message sent ✓");
|
||||||
|
} catch (e) {
|
||||||
|
errEl.textContent = e.message;
|
||||||
|
errEl.classList.remove("hidden");
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Logs ──────────────────────────────────────────────────────────────────────
|
||||||
|
let _logsAutoRefreshTimer = null;
|
||||||
|
|
||||||
|
const _LEVEL_COLOR = {
|
||||||
|
DEBUG: "color:var(--text-muted)",
|
||||||
|
INFO: "color:var(--text)",
|
||||||
|
WARNING: "color:#d97706",
|
||||||
|
ERROR: "color:#ef4444",
|
||||||
|
CRITICAL: "color:#ef4444;font-weight:700",
|
||||||
|
};
|
||||||
|
|
||||||
|
function _colorLogLine(line) {
|
||||||
|
const escaped = esc(line);
|
||||||
|
for (const [lvl, style] of Object.entries(_LEVEL_COLOR)) {
|
||||||
|
if (line.includes(lvl)) {
|
||||||
|
return `<span style="${style}">${escaped}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return escaped;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLogs() {
|
||||||
|
const level = document.getElementById("logs-level").value;
|
||||||
|
const lines = parseInt(document.getElementById("logs-lines").value, 10) || 200;
|
||||||
|
const search = document.getElementById("logs-search").value.trim().toLowerCase();
|
||||||
|
const pre = document.getElementById("logs-output");
|
||||||
|
const noFile = document.getElementById("logs-no-file");
|
||||||
|
const meta = document.getElementById("logs-meta");
|
||||||
|
|
||||||
|
const params = new URLSearchParams({ lines });
|
||||||
|
if (level) params.set("level", level);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api("GET", `/api/admin/logs?${params}`);
|
||||||
|
noFile.classList.add("hidden");
|
||||||
|
pre.parentElement.style.display = "";
|
||||||
|
|
||||||
|
let rows = data.lines;
|
||||||
|
if (search) {
|
||||||
|
rows = rows.filter(l => l.toLowerCase().includes(search));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
pre.innerHTML = '<span style="color:var(--text-muted)">No log entries match the current filters.</span>';
|
||||||
|
} else {
|
||||||
|
pre.innerHTML = rows.map(_colorLogLine).join("\n");
|
||||||
|
// Scroll to bottom so the latest entries are visible
|
||||||
|
pre.scrollTop = pre.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ts = new Date().toLocaleTimeString();
|
||||||
|
meta.textContent = `${rows.length} line${rows.length !== 1 ? "s" : ""} shown${data.log_file ? ` · ${data.log_file}` : ""} · last refreshed ${ts}`;
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message && err.message.includes("not configured")) {
|
||||||
|
noFile.classList.remove("hidden");
|
||||||
|
pre.parentElement.style.display = "none";
|
||||||
|
meta.textContent = "";
|
||||||
|
} else {
|
||||||
|
showToast(err.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _startLogsAutoRefresh() {
|
||||||
|
_stopLogsAutoRefresh();
|
||||||
|
_logsAutoRefreshTimer = setInterval(loadLogs, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _stopLogsAutoRefresh() {
|
||||||
|
if (_logsAutoRefreshTimer !== null) {
|
||||||
|
clearInterval(_logsAutoRefreshTimer);
|
||||||
|
_logsAutoRefreshTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("btn-refresh-logs").addEventListener("click", loadLogs);
|
||||||
|
|
||||||
|
document.getElementById("logs-autorefresh").addEventListener("change", e => {
|
||||||
|
if (e.target.checked) _startLogsAutoRefresh();
|
||||||
|
else _stopLogsAutoRefresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stop auto-refresh when leaving the logs tab; restart when entering it
|
||||||
|
document.querySelectorAll(".tab-btn").forEach(btn => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
if (btn.dataset.tab === "logs") {
|
||||||
|
if (document.getElementById("logs-autorefresh").checked) {
|
||||||
|
_startLogsAutoRefresh();
|
||||||
|
}
|
||||||
|
loadLogs();
|
||||||
|
} else {
|
||||||
|
_stopLogsAutoRefresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── Load all data ─────────────────────────────────────────────────────────────
|
// ── Load all data ─────────────────────────────────────────────────────────────
|
||||||
function loadAllData() {
|
function loadAllData() {
|
||||||
const isAdmin = _tokenPayload().scope === "admin";
|
const isAdmin = _tokenPayload().scope === "admin";
|
||||||
@@ -596,6 +1121,7 @@ function loadAllData() {
|
|||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
loadCredentials();
|
loadCredentials();
|
||||||
loadAdmins();
|
loadAdmins();
|
||||||
|
loadTelegram();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
/* app.js - Lagomare Gates frontend */
|
/* app.js - Lagomare Gates frontend */
|
||||||
|
|
||||||
|
// ── Leaflet icon fix (assets served from /static/) ───────────────────────────
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconUrl: "/static/images/marker-icon.png",
|
||||||
|
iconRetinaUrl: "/static/images/marker-icon-2x.png",
|
||||||
|
shadowUrl: "/static/images/marker-shadow.png",
|
||||||
|
});
|
||||||
|
|
||||||
// ── Token helpers ─────────────────────────────────────────────────────────────
|
// ── Token helpers ─────────────────────────────────────────────────────────────
|
||||||
const TOKEN_KEY = "lg_keypass_token";
|
const TOKEN_KEY = "lg_keypass_token";
|
||||||
|
|
||||||
@@ -49,6 +56,94 @@ function showGatesView() {
|
|||||||
document.getElementById("gates-view").classList.remove("hidden");
|
document.getElementById("gates-view").classList.remove("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Map ───────────────────────────────────────────────────────────────────────
|
||||||
|
let _map = null;
|
||||||
|
let _mapMarkers = [];
|
||||||
|
let _homeMarker = null;
|
||||||
|
|
||||||
|
const _homeIcon = L.divIcon({
|
||||||
|
className: "",
|
||||||
|
html: '<div style="font-size:1.6rem;line-height:1;filter:drop-shadow(0 1px 3px rgba(0,0,0,.5))">🏠</div>',
|
||||||
|
iconSize: [28, 28],
|
||||||
|
iconAnchor: [14, 24],
|
||||||
|
popupAnchor: [0, -24],
|
||||||
|
});
|
||||||
|
const _gateIcon = () => L.icon({
|
||||||
|
iconUrl: "/static/images/gate.svg",
|
||||||
|
iconSize: [36, 36],
|
||||||
|
iconAnchor: [18, 36],
|
||||||
|
popupAnchor: [0, -38],
|
||||||
|
});
|
||||||
|
|
||||||
|
async function _ensureMapReady() {
|
||||||
|
if (_map) return;
|
||||||
|
let siteConfig = null;
|
||||||
|
try { siteConfig = await fetch("/api/site-config").then(r => r.json()); } catch { /* ignore */ }
|
||||||
|
_map = L.map("map", { zoomControl: true });
|
||||||
|
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||||
|
maxZoom: 19,
|
||||||
|
}).addTo(_map);
|
||||||
|
if (siteConfig && siteConfig.home) {
|
||||||
|
_homeMarker = L.marker([siteConfig.home.lat, siteConfig.home.lon], { icon: _homeIcon })
|
||||||
|
.bindPopup(`<div style="min-width:140px">
|
||||||
|
<strong>${siteConfig.home.name}</strong><br>
|
||||||
|
<a href="https://www.google.com/maps/dir/?api=1&destination=${siteConfig.home.lat},${siteConfig.home.lon}"
|
||||||
|
target="_blank" rel="noopener"
|
||||||
|
style="display:inline-block;margin-top:.5em;font-size:.85em;text-decoration:underline">
|
||||||
|
Get directions
|
||||||
|
</a>
|
||||||
|
</div>`)
|
||||||
|
.addTo(_map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _fitMap() {
|
||||||
|
if (!_map) return;
|
||||||
|
const bounds = [];
|
||||||
|
if (_homeMarker) { const ll = _homeMarker.getLatLng(); bounds.push([ll.lat, ll.lng]); }
|
||||||
|
_mapMarkers.forEach(m => { const ll = m.getLatLng(); bounds.push([ll.lat, ll.lng]); });
|
||||||
|
if (bounds.length === 1) _map.setView(bounds[0], 16);
|
||||||
|
else if (bounds.length > 1) _map.fitBounds(bounds, { padding: [32, 32], maxZoom: 17 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateMap(gates) {
|
||||||
|
await _ensureMapReady();
|
||||||
|
const gatesWithCoords = gates.filter(g => g.lat != null && g.lon != null);
|
||||||
|
if (!_homeMarker && gatesWithCoords.length === 0) return; // nothing to put on the map
|
||||||
|
document.getElementById("map-btn").classList.remove("hidden");
|
||||||
|
_mapMarkers.forEach(m => m.remove());
|
||||||
|
_mapMarkers = [];
|
||||||
|
for (const gate of gatesWithCoords) {
|
||||||
|
const popup = L.popup().setContent(
|
||||||
|
`<div style="min-width:140px">
|
||||||
|
<strong>${gate.name}</strong><br>
|
||||||
|
<a href="https://www.google.com/maps/dir/?api=1&destination=${gate.lat},${gate.lon}"
|
||||||
|
target="_blank" rel="noopener"
|
||||||
|
style="display:inline-block;margin-top:.5em;font-size:.85em;text-decoration:underline">
|
||||||
|
Get directions
|
||||||
|
</a>
|
||||||
|
</div>`
|
||||||
|
);
|
||||||
|
const marker = L.marker([gate.lat, gate.lon], { icon: _gateIcon() })
|
||||||
|
.bindPopup(popup)
|
||||||
|
.addTo(_map);
|
||||||
|
_mapMarkers.push(marker);
|
||||||
|
}
|
||||||
|
_fitMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("map-btn").addEventListener("click", () => {
|
||||||
|
document.getElementById("map-modal").classList.remove("hidden");
|
||||||
|
if (_map) setTimeout(() => { _map.invalidateSize(); _fitMap(); }, 50);
|
||||||
|
});
|
||||||
|
document.getElementById("map-close").addEventListener("click", () => {
|
||||||
|
document.getElementById("map-modal").classList.add("hidden");
|
||||||
|
});
|
||||||
|
document.getElementById("map-modal").addEventListener("click", e => {
|
||||||
|
if (e.target === e.currentTarget) document.getElementById("map-modal").classList.add("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
// ── Gate rendering ────────────────────────────────────────────────────────────
|
// ── Gate rendering ────────────────────────────────────────────────────────────
|
||||||
function renderGates(gates) {
|
function renderGates(gates) {
|
||||||
const grid = document.getElementById("gates-grid");
|
const grid = document.getElementById("gates-grid");
|
||||||
@@ -64,15 +159,46 @@ function renderGates(gates) {
|
|||||||
loading.classList.add("hidden");
|
loading.classList.add("hidden");
|
||||||
grid.classList.remove("hidden");
|
grid.classList.remove("hidden");
|
||||||
|
|
||||||
|
// Group gates by group_name; null/empty = ungrouped (rendered last, no heading)
|
||||||
|
const groups = new Map();
|
||||||
for (const gate of gates) {
|
for (const gate of gates) {
|
||||||
const icon = gate.gate_type === "car" ? "🚘" : "🚶";
|
const key = gate.group_name || "";
|
||||||
const label = gate.gate_type === "car" ? "Car" : "Pedestrian";
|
if (!groups.has(key)) groups.set(key, []);
|
||||||
const btn = document.createElement("button");
|
groups.get(key).push(gate);
|
||||||
btn.className = `gate-btn ${gate.gate_type}`;
|
}
|
||||||
btn.dataset.gateId = gate.id;
|
const hasNamedGroups = [...groups.keys()].some(k => k !== "");
|
||||||
btn.innerHTML = `<span class="icon">${icon}</span><span>${gate.name}</span>`;
|
const sortedKeys = [...groups.keys()].sort((a, b) => {
|
||||||
btn.addEventListener("click", () => handleOpenGate(btn, gate));
|
if (a === "" && b !== "") return 1;
|
||||||
grid.appendChild(btn);
|
if (a !== "" && b === "") return -1;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const key of sortedKeys) {
|
||||||
|
const section = document.createElement("div");
|
||||||
|
section.className = "gate-group";
|
||||||
|
|
||||||
|
if (hasNamedGroups && key) {
|
||||||
|
const title = document.createElement("div");
|
||||||
|
title.className = "gate-group-title";
|
||||||
|
title.textContent = key;
|
||||||
|
section.appendChild(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupGrid = document.createElement("div");
|
||||||
|
groupGrid.className = "gate-group-grid";
|
||||||
|
|
||||||
|
for (const gate of groups.get(key)) {
|
||||||
|
const icon = gate.gate_icon || '🚪';
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.className = "gate-btn";
|
||||||
|
btn.dataset.gateId = gate.id;
|
||||||
|
btn.innerHTML = `<span class="icon">${icon}</span><span>${gate.name}</span>`;
|
||||||
|
btn.addEventListener("click", () => handleOpenGate(btn, gate));
|
||||||
|
groupGrid.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
section.appendChild(groupGrid);
|
||||||
|
grid.appendChild(section);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +206,7 @@ async function loadGates() {
|
|||||||
try {
|
try {
|
||||||
const gates = await apiFetch("GET", "/api/gates");
|
const gates = await apiFetch("GET", "/api/gates");
|
||||||
renderGates(gates);
|
renderGates(gates);
|
||||||
|
updateMap(gates);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.getElementById("loading-gates").textContent = e.message;
|
document.getElementById("loading-gates").textContent = e.message;
|
||||||
document.getElementById("loading-gates").classList.remove("hidden");
|
document.getElementById("loading-gates").classList.remove("hidden");
|
||||||
@@ -173,6 +300,33 @@ document.getElementById("logout-btn").addEventListener("click", () => {
|
|||||||
|
|
||||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||||
(function init() {
|
(function init() {
|
||||||
|
// Auto-login when the URL contains ?k=CODE (e.g. scanned from a QR code)
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const k = params.get("k");
|
||||||
|
if (k) {
|
||||||
|
// Remove the code from the URL immediately so it doesn't linger in history
|
||||||
|
history.replaceState(null, "", window.location.pathname);
|
||||||
|
fetch("/api/auth/keypass", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ code: k.toUpperCase() }),
|
||||||
|
})
|
||||||
|
.then(res => res.ok ? res.json() : res.json().then(j => Promise.reject(j.detail || "Invalid keypass")))
|
||||||
|
.then(data => {
|
||||||
|
saveToken(data.token);
|
||||||
|
showGatesView();
|
||||||
|
loadGates();
|
||||||
|
})
|
||||||
|
.catch(msg => {
|
||||||
|
clearToken();
|
||||||
|
showLogin();
|
||||||
|
const errEl = document.getElementById("login-error");
|
||||||
|
errEl.textContent = typeof msg === "string" ? msg : "QR code login failed";
|
||||||
|
errEl.classList.remove("hidden");
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const t = getToken();
|
const t = getToken();
|
||||||
if (tokenValid(t)) {
|
if (tokenValid(t)) {
|
||||||
showGatesView();
|
showGatesView();
|
||||||
@@ -188,33 +342,43 @@ if ("serviceWorker" in navigator) {
|
|||||||
navigator.serviceWorker.register("/sw.js").catch(() => {});
|
navigator.serviceWorker.register("/sw.js").catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── PWA install banner ────────────────────────────────────────────────────────
|
// ── Add to Home Screen ────────────────────────────────────────────────────────
|
||||||
|
const _isInstalled = window.matchMedia("(display-mode: standalone)").matches || !!navigator.standalone;
|
||||||
|
const _installAppBtn = document.getElementById("install-app-btn");
|
||||||
|
const _installBanner = document.getElementById("install-banner");
|
||||||
const INSTALL_DISMISSED_KEY = "lg_install_dismissed";
|
const INSTALL_DISMISSED_KEY = "lg_install_dismissed";
|
||||||
let _deferredInstallPrompt = null;
|
|
||||||
|
|
||||||
window.addEventListener("beforeinstallprompt", (e) => {
|
if (_isInstalled) {
|
||||||
e.preventDefault();
|
// Already installed — hide both
|
||||||
if (sessionStorage.getItem(INSTALL_DISMISSED_KEY)) return;
|
_installAppBtn.style.display = "none";
|
||||||
_deferredInstallPrompt = e;
|
} else if (typeof window.AddToHomeScreen === "function") {
|
||||||
document.getElementById("install-banner").classList.remove("hidden");
|
const _addToHomeScreenInstance = window.AddToHomeScreen({
|
||||||
});
|
appName: "Lagomare Gates",
|
||||||
|
appIconUrl: "/static/images/mobile_icon.png",
|
||||||
|
assetUrl: "/static/add-to-homescreen/assets/img/",
|
||||||
|
maxModalDisplayCount: -1,
|
||||||
|
displayOptions: { showMobile: true, showDesktop: true },
|
||||||
|
allowClose: true,
|
||||||
|
showArrow: true,
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById("install-btn").addEventListener("click", async () => {
|
// Header button — always triggers the library UI
|
||||||
const banner = document.getElementById("install-banner");
|
_installAppBtn.addEventListener("click", () => {
|
||||||
banner.classList.add("hidden");
|
_addToHomeScreenInstance.show("en");
|
||||||
if (!_deferredInstallPrompt) return;
|
});
|
||||||
_deferredInstallPrompt.prompt();
|
|
||||||
await _deferredInstallPrompt.userChoice;
|
|
||||||
_deferredInstallPrompt = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("install-dismiss").addEventListener("click", () => {
|
// Auto-show banner once per session
|
||||||
document.getElementById("install-banner").classList.add("hidden");
|
if (!sessionStorage.getItem(INSTALL_DISMISSED_KEY)) {
|
||||||
sessionStorage.setItem(INSTALL_DISMISSED_KEY, "1");
|
_installBanner.classList.remove("hidden");
|
||||||
_deferredInstallPrompt = null;
|
}
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener("appinstalled", () => {
|
document.getElementById("install-btn").addEventListener("click", () => {
|
||||||
document.getElementById("install-banner").classList.add("hidden");
|
_installBanner.classList.add("hidden");
|
||||||
_deferredInstallPrompt = null;
|
_addToHomeScreenInstance.show("en");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById("install-dismiss").addEventListener("click", () => {
|
||||||
|
_installBanner.classList.add("hidden");
|
||||||
|
sessionStorage.setItem(INSTALL_DISMISSED_KEY, "1");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
1
src/static/images/add_to_home_screen.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M0 0h24v24H0V0z" fill="none"/><path d="M18 1.01L8 1c-1.1 0-2 .9-2 2v3h2V5h10v14H8v-1H6v3c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-1.99-2-1.99zM10 15h2V8H5v2h3.59L3 15.59 4.41 17 10 11.41z"/></svg>
|
||||||
|
After Width: | Height: | Size: 329 B |
1
src/static/images/gate.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9 6V11H7V7H5V11H3V9H1V21H3V19H5V21H7V19H9V21H11V19H13V21H15V19H17V21H19V19H21V21H23V9H21V11H19V7H17V11H15V6H13V11H11V6H9M3 13H5V17H3V13M7 13H9V17H7V13M11 13H13V17H11V13M15 13H17V17H15V13M19 13H21V17H19V13Z" /></svg>
|
||||||
|
After Width: | Height: | Size: 285 B |
@@ -23,12 +23,12 @@
|
|||||||
inkscape:pagecheckerboard="0"
|
inkscape:pagecheckerboard="0"
|
||||||
inkscape:deskcolor="#d1d1d1"
|
inkscape:deskcolor="#d1d1d1"
|
||||||
inkscape:document-units="mm"
|
inkscape:document-units="mm"
|
||||||
inkscape:zoom="0.5096119"
|
inkscape:zoom="0.72070006"
|
||||||
inkscape:cx="651.47616"
|
inkscape:cx="337.17217"
|
||||||
inkscape:cy="528.83381"
|
inkscape:cy="461.35698"
|
||||||
inkscape:window-width="1912"
|
inkscape:window-width="1912"
|
||||||
inkscape:window-height="1012"
|
inkscape:window-height="1012"
|
||||||
inkscape:window-x="3844"
|
inkscape:window-x="1924"
|
||||||
inkscape:window-y="64"
|
inkscape:window-y="64"
|
||||||
inkscape:window-maximized="1"
|
inkscape:window-maximized="1"
|
||||||
inkscape:current-layer="g17635" /><defs
|
inkscape:current-layer="g17635" /><defs
|
||||||
@@ -48,6 +48,6 @@
|
|||||||
d="m 98.821875,266.26028 c -16.908838,-1.37674 -31.369652,-5.41867 -44.8189,-12.52729 -5.27602,-2.78865 -5.363582,-2.54035 0.961449,-2.72632 12.928382,-0.38012 27.983323,-2.9765 51.640756,-8.90599 29.16078,-7.30885 48.62869,-8.92742 68.19274,-5.66955 6.323,1.05292 6.36325,0.20089 -0.27106,5.73791 -4.73434,3.95129 -20.98907,15.40667 -40.17232,20.54385 -10.94342,2.93061 -25.39192,4.37307 -35.532665,3.54739 z M 38.354739,245.77789 c -8.423996,-0.36965 -18.734652,-2.00339 -26.580776,-4.21174 -3.7335551,-1.05084 -11.60348692,-3.65489 -11.11249904,-3.67697 0.14552083,-0.007 1.87192694,0.16411 3.83645814,0.37923 22.7878619,2.49532 55.0113749,-1.06012 90.0970339,-9.94101 38.263784,-9.68533 55.558294,-10.77971 85.453994,-5.40744 3.2103,0.5769 10.78472,2.12065 11.68452,2.38144 0.16909,0.049 -0.30201,0.81348 -1.12051,1.62771 -1.75286,1.74373 -2.5477,2.39722 -3.82477,3.67197 -1.48656,1.50769 -1.81674,1.72197 -2.5059,1.62624 -0.43656,-0.0606 -2.57969,-0.4208 -4.7625,-0.80035 -13.14413,-2.28551 -27.95175,-2.84874 -39.95208,-1.51965 -14.04132,1.55514 -19.83368,2.63824 -37.57083,7.02532 -25.395747,6.28133 -36.363028,8.20339 -50.535419,8.85656 -6.291794,0.28997 -6.239963,0.29002 -13.106722,-0.0113 z"
|
d="m 98.821875,266.26028 c -16.908838,-1.37674 -31.369652,-5.41867 -44.8189,-12.52729 -5.27602,-2.78865 -5.363582,-2.54035 0.961449,-2.72632 12.928382,-0.38012 27.983323,-2.9765 51.640756,-8.90599 29.16078,-7.30885 48.62869,-8.92742 68.19274,-5.66955 6.323,1.05292 6.36325,0.20089 -0.27106,5.73791 -4.73434,3.95129 -20.98907,15.40667 -40.17232,20.54385 -10.94342,2.93061 -25.39192,4.37307 -35.532665,3.54739 z M 38.354739,245.77789 c -8.423996,-0.36965 -18.734652,-2.00339 -26.580776,-4.21174 -3.7335551,-1.05084 -11.60348692,-3.65489 -11.11249904,-3.67697 0.14552083,-0.007 1.87192694,0.16411 3.83645814,0.37923 22.7878619,2.49532 55.0113749,-1.06012 90.0970339,-9.94101 38.263784,-9.68533 55.558294,-10.77971 85.453994,-5.40744 3.2103,0.5769 10.78472,2.12065 11.68452,2.38144 0.16909,0.049 -0.30201,0.81348 -1.12051,1.62771 -1.75286,1.74373 -2.5477,2.39722 -3.82477,3.67197 -1.48656,1.50769 -1.81674,1.72197 -2.5059,1.62624 -0.43656,-0.0606 -2.57969,-0.4208 -4.7625,-0.80035 -13.14413,-2.28551 -27.95175,-2.84874 -39.95208,-1.51965 -14.04132,1.55514 -19.83368,2.63824 -37.57083,7.02532 -25.395747,6.28133 -36.363028,8.20339 -50.535419,8.85656 -6.291794,0.28997 -6.239963,0.29002 -13.106722,-0.0113 z"
|
||||||
id="path17636"
|
id="path17636"
|
||||||
sodipodi:nodetypes="sssssssssscsssssssssssss" /><path
|
sodipodi:nodetypes="sssssssssscsssssssssssss" /><path
|
||||||
style="fill:#018133"
|
style="fill:#018133;stroke:#018133;stroke-opacity:1"
|
||||||
d="m 21.926324,229.19408 c -1.921696,-2.03737 -6.600013,-7.59333 -6.66777,-7.91863 -0.02475,-0.11884 1.979055,-0.36339 4.452909,-0.54345 14.173177,-1.03154 29.091327,-3.75294 47.757289,-8.71197 41.893298,-11.12989 68.057038,-12.00796 116.485848,-3.90934 7.52339,1.25812 18.20849,3.47553 18.94699,3.93195 0.24905,0.15392 -5.53193,8.13374 -6.42421,8.8677 -0.52057,0.42821 -0.17862,0.50076 -11.13676,-2.363 -27.27312,-7.12747 -49.12082,-8.03892 -75.80312,-3.16239 -8.13419,1.48663 -14.456106,3.0035 -27.952021,6.70675 -23.834435,6.54013 -37.829029,8.88515 -53.044572,8.88847 -4.928889,0.001 -4.928889,0.001 -6.614583,-1.78609 z M 10.940218,215.18929 C 10.7727,215.07086 9.308527,213.07792 7.6865011,210.76054 -46.070447,133.95833 10.150848,28.258087 104.775,28.226888 c 91.16273,-0.03006 148.15706,96.004452 103.65712,174.660552 -2.29018,4.04801 -0.94258,3.84897 -12.18189,1.79932 -56.07762,-10.22656 -79.88335,-9.81532 -127.987728,2.21095 -16.750944,4.1878 -25.743811,5.94968 -36.380206,7.12762 -8.122432,0.89952 -20.354505,1.57938 -20.942078,1.16396 z M 160.09104,190.34247 c 5.6835,-1.6694 6.92602,-3.65638 2.85988,-4.57337 -6.97918,-1.57394 -12.00605,-3.58885 -20.17398,-8.08627 -4.12981,-2.27396 -5.35747,-3.05783 -4.78903,-3.05783 0.0942,0 2.03597,-0.42732 4.31509,-0.94959 2.27913,-0.52227 5.33324,-1.14735 6.78692,-1.38906 5.04315,-0.83856 2.13904,-3.69129 -12.43263,-12.21268 -16.28141,-9.52123 -20.26575,-14.88552 -22.20725,-29.89867 -2.81089,-21.73586 1.89757,-46.535249 13.85881,-72.994131 4.72428,-10.450335 2.92396,-11.978673 -7.79114,-6.614124 -20.41528,10.220968 -35.46783,32.692089 -40.742914,60.822835 -4.304937,22.9572 -5.164514,25.32568 -10.024281,27.62092 -6.201621,2.929 -12.243013,2.35805 -21.954314,-2.0748 -12.93987,-5.90657 -19.12784,-4.78933 -25.171529,4.54475 -1.033332,1.59591 -1.033332,1.59591 -5.015979,3.34328 -3.963534,1.73897 -6.201655,3.01272 -7.6562336,4.35728 -1.7500875,1.61772 -3.0197311,4.55572 -1.8165167,4.2035 8.8532733,-2.59171 10.5706133,-2.89136 16.4703113,-2.87384 12.052398,0.0358 20.193637,2.64899 31.486663,10.10664 20.499318,13.53727 34.004722,17.54724 60.456043,17.95037 13.50401,0.2058 14.60156,0.51877 26.32604,7.50685 7.58957,4.52357 12.33804,5.70074 17.21604,4.26794 z"
|
d="m 21.926324,229.19408 c -1.921696,-2.03737 -6.600013,-7.59333 -6.66777,-7.91863 -0.02475,-0.11884 1.979055,-0.36339 4.452909,-0.54345 14.173177,-1.03154 29.091327,-3.75294 47.757289,-8.71197 41.893298,-11.12989 68.057038,-12.00796 116.485848,-3.90934 7.52339,1.25812 18.20849,3.47553 18.94699,3.93195 0.24905,0.15392 -5.53193,8.13374 -6.42421,8.8677 -0.52057,0.42821 -0.17862,0.50076 -11.13676,-2.363 -27.27312,-7.12747 -49.12082,-8.03892 -75.80312,-3.16239 -8.13419,1.48663 -14.456106,3.0035 -27.952021,6.70675 -23.834435,6.54013 -37.829029,8.88515 -53.044572,8.88847 -4.928889,0.001 -4.928889,0.001 -6.614583,-1.78609 z M 10.940218,215.18929 C 10.7727,215.07086 9.308527,213.07792 7.6865011,210.76054 -46.070447,133.95833 10.150848,28.258087 104.775,28.226888 c 91.16273,-0.03006 148.15706,96.004452 103.65712,174.660552 -2.29018,4.04801 -0.94258,3.84897 -12.18189,1.79932 -56.07762,-10.22656 -79.88335,-9.81532 -127.987728,2.21095 -16.750944,4.1878 -25.743811,5.94968 -36.380206,7.12762 -8.122432,0.89952 -20.354505,1.57938 -20.942078,1.16396 z M 160.09104,190.34247 c 5.6835,-1.6694 6.92602,-3.65638 2.85988,-4.57337 -6.97918,-1.57394 -12.00605,-3.58885 -20.17398,-8.08627 -4.12981,-2.27396 -5.35747,-3.05783 -4.78903,-3.05783 0.0942,0 2.03597,-0.42732 4.31509,-0.94959 2.27913,-0.52227 5.33324,-1.14735 6.78692,-1.38906 5.04315,-0.83856 2.13904,-3.69129 -12.43263,-12.21268 -16.28141,-9.52123 -20.26575,-14.88552 -22.20725,-29.89867 -2.81089,-21.73586 1.89757,-46.535249 13.85881,-72.994131 4.72428,-10.450335 2.92396,-11.978673 -7.79114,-6.614124 -20.41528,10.220968 -35.46783,32.692089 -40.742914,60.822835 -4.304937,22.9572 -5.164514,25.32568 -10.024281,27.62092 -6.201621,2.929 -12.243013,2.35805 -21.954314,-2.0748 -12.93987,-5.90657 -19.12784,-4.78933 -25.171529,4.54475 -1.033332,1.59591 -1.033332,1.59591 -5.015979,3.34328 -3.963534,1.73897 -6.201655,3.01272 -7.6562336,4.35728 -1.7500875,1.61772 -3.0197311,4.55572 -1.8165167,4.2035 8.8532733,-2.59171 10.5706133,-2.89136 16.4703113,-2.87384 12.052398,0.0358 20.193637,2.64899 31.486663,10.10664 20.499318,13.53727 34.004722,17.54724 60.456043,17.95037 13.50401,0.2058 14.60156,0.51877 26.32604,7.50685 7.58957,4.52357 12.33804,5.70074 17.21604,4.26794 z"
|
||||||
id="path17635" /></g></g></svg>
|
id="path17635" /></g></g></svg>
|
||||||
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.4 KiB |
1
src/static/images/map.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M15,19L9,16.89V5L15,7.11M20.5,3C20.44,3 20.39,3 20.34,3L15,5.1L9,3L3.36,4.9C3.15,4.97 3,5.15 3,5.38V20.5A0.5,0.5 0 0,0 3.5,21C3.55,21 3.61,21 3.66,20.97L9,18.9L15,21L20.64,19.1C20.85,19 21,18.85 21,18.62V3.5A0.5,0.5 0 0,0 20.5,3Z" /></svg>
|
||||||
|
After Width: | Height: | Size: 308 B |
BIN
src/static/images/marker-icon-2x.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src/static/images/marker-icon.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/static/images/marker-shadow.png
Normal file
|
After Width: | Height: | Size: 618 B |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
BIN
src/static/images/screenshot-mobile.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src/static/images/screenshot-wide.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
@@ -3,14 +3,16 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#0f0f1a" />
|
<meta name="theme-color" content="#018133ff" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<title>Lagomare Gates</title>
|
<title>Lagomare Gates</title>
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/logo.svg" />
|
<link rel="icon" type="image/svg+xml" href="/static/images/logo.svg" />
|
||||||
<link rel="apple-touch-icon" href="/static/mobile_icon.png" />
|
<link rel="apple-touch-icon" href="/static/images/mobile_icon.png" />
|
||||||
<link rel="stylesheet" href="/static/style.css" />
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
|
<link rel="stylesheet" href="/static/leaflet/leaflet.css" />
|
||||||
|
<link rel="stylesheet" href="/static/add-to-homescreen/add-to-homescreen.min.css" />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* ── Login view ──────────────────────────────────────────────────────── */
|
/* ── Login view ──────────────────────────────────────────────────────── */
|
||||||
@@ -55,7 +57,6 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
@@ -64,11 +65,24 @@
|
|||||||
.app-header .sub { color: var(--text-muted); font-size: .8rem; }
|
.app-header .sub { color: var(--text-muted); font-size: .8rem; }
|
||||||
|
|
||||||
#gates-grid {
|
#gates-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.gate-group-title {
|
||||||
|
font-size: .8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
margin-bottom: .6rem;
|
||||||
|
}
|
||||||
|
.gate-group-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 1.25rem;
|
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
/* ── Loading state ───────────────────────────────────────────────────── */
|
/* ── Loading state ───────────────────────────────────────────────────── */
|
||||||
#loading-gates {
|
#loading-gates {
|
||||||
@@ -78,16 +92,33 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Map modal ─────────────────────────────────────────────────────── */
|
||||||
|
#map-modal .modal {
|
||||||
|
width: min(96vw, 700px);
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#map-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: .85rem 1.1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
#map-modal-header h3 { font-size: 1rem; font-weight: 700; }
|
||||||
|
#map { height: min(60vh, 480px); }
|
||||||
|
/* Fix Leaflet default icon paths */
|
||||||
|
.leaflet-default-icon-path { background-image: url(/static/images/marker-icon.png); }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- ── Login view ──────────────────────────────────────────────────────── -->
|
<!-- ── Login view ──────────────────────────────────────────────────────── -->
|
||||||
<div id="login-view">
|
<div id="login-view">
|
||||||
<img src="/static/logo.svg" alt="Lagomare" style="width:72px;height:72px;object-fit:contain;margin-bottom:.5rem" />
|
<img src="/static/images/logo.svg" alt="Lagomare" style="width:72px;height:72px;object-fit:contain;margin-bottom:.5rem" />
|
||||||
<h1>Lagomare Gates</h1>
|
<h1>Lagomare Gates</h1>
|
||||||
<p class="subtitle">Enter your keypass to continue</p>
|
<div class="card" style="margin-top:2rem">
|
||||||
<div class="card">
|
|
||||||
<form id="login-form">
|
<form id="login-form">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="keypass-input">Keypass</label>
|
<label for="keypass-input">Keypass</label>
|
||||||
@@ -99,7 +130,7 @@
|
|||||||
autocapitalize="characters"
|
autocapitalize="characters"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
placeholder="Enter keypass…"
|
placeholder="Enter keypass…"
|
||||||
maxlength="20"
|
maxlength="40"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p id="login-error" class="error-msg hidden"></p>
|
<p id="login-error" class="error-msg hidden"></p>
|
||||||
@@ -113,21 +144,35 @@
|
|||||||
<!-- ── Gates view ──────────────────────────────────────────────────────── -->
|
<!-- ── Gates view ──────────────────────────────────────────────────────── -->
|
||||||
<div id="gates-view" class="hidden">
|
<div id="gates-view" class="hidden">
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div style="display:flex;align-items:center;gap:.6rem">
|
<div style="display:flex;align-items:center;gap:.75rem">
|
||||||
<img src="/static/logo.svg" alt="" style="width:32px;height:32px;object-fit:contain" />
|
<img src="/static/images/logo.svg" alt="" style="width:50px;height:50px;object-fit:contain;flex-shrink:0" />
|
||||||
<div>
|
<div class="app-header h2">Lagomare Gates</div>
|
||||||
<div class="app-header h2">Lagomare Gates</div>
|
</div>
|
||||||
<div class="sub">Select a gate to open</div>
|
<div style="display:flex;align-items:center;gap:.5rem">
|
||||||
</div>
|
<button id="install-app-btn" class="btn btn-primary" style="padding:.4rem .6rem;line-height:0" aria-label="Add to Home Screen" title="Add to Home Screen">
|
||||||
|
<img src="/static/images/add_to_home_screen.svg" alt="" style="width:22px;height:22px;display:block;filter:invert(1)" />
|
||||||
|
</button>
|
||||||
|
<button id="map-btn" class="btn btn-ghost hidden" style="padding:.4rem .6rem;line-height:0" aria-label="Show map">
|
||||||
|
<img src="/static/images/map.svg" alt="Map" style="width:22px;height:22px;filter:invert(1)" />
|
||||||
|
</button>
|
||||||
|
<button id="logout-btn" class="btn btn-ghost" style="font-size:.85rem;padding:.5rem 1rem">Logout</button>
|
||||||
</div>
|
</div>
|
||||||
<button id="logout-btn" class="btn btn-ghost" style="font-size:.85rem;padding:.5rem 1rem">
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</header>
|
</header>
|
||||||
<div id="loading-gates">Loading gates…</div>
|
<div id="loading-gates">Loading gates…</div>
|
||||||
<div id="gates-grid" class="hidden"></div>
|
<div id="gates-grid" class="hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Map modal ──────────────────────────────────────────────────────── -->
|
||||||
|
<div id="map-modal" class="modal-backdrop hidden">
|
||||||
|
<div class="modal">
|
||||||
|
<div id="map-modal-header">
|
||||||
|
<h3>Map</h3>
|
||||||
|
<button id="map-close" class="btn btn-ghost" style="padding:.35rem .8rem;font-size:.85rem">✕</button>
|
||||||
|
</div>
|
||||||
|
<div id="map"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── Toast ───────────────────────────────────────────────────────────── -->
|
<!-- ── Toast ───────────────────────────────────────────────────────────── -->
|
||||||
<div id="toast" class="toast hidden" aria-live="assertive"></div>
|
<div id="toast" class="toast hidden" aria-live="assertive"></div>
|
||||||
|
|
||||||
@@ -143,10 +188,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── PWA install banner ──────────────────────────────────────────────── -->
|
<!-- ── PWA install banner ─────────────────────────────────────────────────────── -->
|
||||||
<div id="install-banner" class="install-banner hidden" role="banner" aria-label="Install app">
|
<div id="install-banner" class="install-banner hidden" role="banner" aria-label="Install app">
|
||||||
<div class="install-banner-body">
|
<div class="install-banner-body">
|
||||||
<img src="/static/logo.svg" alt="" class="install-banner-icon" />
|
<img src="/static/images/logo.svg" alt="" class="install-banner-icon" />
|
||||||
<div class="install-banner-text">
|
<div class="install-banner-text">
|
||||||
<strong>Add to Home Screen</strong>
|
<strong>Add to Home Screen</strong>
|
||||||
<span>Install Lagomare Gates for quick access</span>
|
<span>Install Lagomare Gates for quick access</span>
|
||||||
@@ -158,6 +203,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/leaflet/leaflet.js"></script>
|
||||||
|
<script src="/static/add-to-homescreen/add-to-homescreen.min.js"></script>
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
661
src/static/leaflet/leaflet.css
Normal file
@@ -0,0 +1,661 @@
|
|||||||
|
/* required styles */
|
||||||
|
|
||||||
|
.leaflet-pane,
|
||||||
|
.leaflet-tile,
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow,
|
||||||
|
.leaflet-tile-container,
|
||||||
|
.leaflet-pane > svg,
|
||||||
|
.leaflet-pane > canvas,
|
||||||
|
.leaflet-zoom-box,
|
||||||
|
.leaflet-image-layer,
|
||||||
|
.leaflet-layer {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.leaflet-container {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.leaflet-tile,
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
}
|
||||||
|
/* Prevents IE11 from highlighting tiles in blue */
|
||||||
|
.leaflet-tile::selection {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||||
|
.leaflet-safari .leaflet-tile {
|
||||||
|
image-rendering: -webkit-optimize-contrast;
|
||||||
|
}
|
||||||
|
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||||
|
.leaflet-safari .leaflet-tile-container {
|
||||||
|
width: 1600px;
|
||||||
|
height: 1600px;
|
||||||
|
-webkit-transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||||
|
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||||
|
.leaflet-container .leaflet-overlay-pane svg {
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: none !important;
|
||||||
|
}
|
||||||
|
.leaflet-container .leaflet-marker-pane img,
|
||||||
|
.leaflet-container .leaflet-shadow-pane img,
|
||||||
|
.leaflet-container .leaflet-tile-pane img,
|
||||||
|
.leaflet-container img.leaflet-image-layer,
|
||||||
|
.leaflet-container .leaflet-tile {
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: none !important;
|
||||||
|
width: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-container img.leaflet-tile {
|
||||||
|
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||||
|
mix-blend-mode: plus-lighter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-container.leaflet-touch-zoom {
|
||||||
|
-ms-touch-action: pan-x pan-y;
|
||||||
|
touch-action: pan-x pan-y;
|
||||||
|
}
|
||||||
|
.leaflet-container.leaflet-touch-drag {
|
||||||
|
-ms-touch-action: pinch-zoom;
|
||||||
|
/* Fallback for FF which doesn't support pinch-zoom */
|
||||||
|
touch-action: none;
|
||||||
|
touch-action: pinch-zoom;
|
||||||
|
}
|
||||||
|
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||||
|
-ms-touch-action: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.leaflet-container {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
.leaflet-container a {
|
||||||
|
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||||
|
}
|
||||||
|
.leaflet-tile {
|
||||||
|
filter: inherit;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
.leaflet-tile-loaded {
|
||||||
|
visibility: inherit;
|
||||||
|
}
|
||||||
|
.leaflet-zoom-box {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
-moz-box-sizing: border-box;
|
||||||
|
box-sizing: border-box;
|
||||||
|
z-index: 800;
|
||||||
|
}
|
||||||
|
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||||
|
.leaflet-overlay-pane svg {
|
||||||
|
-moz-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-pane { z-index: 400; }
|
||||||
|
|
||||||
|
.leaflet-tile-pane { z-index: 200; }
|
||||||
|
.leaflet-overlay-pane { z-index: 400; }
|
||||||
|
.leaflet-shadow-pane { z-index: 500; }
|
||||||
|
.leaflet-marker-pane { z-index: 600; }
|
||||||
|
.leaflet-tooltip-pane { z-index: 650; }
|
||||||
|
.leaflet-popup-pane { z-index: 700; }
|
||||||
|
|
||||||
|
.leaflet-map-pane canvas { z-index: 100; }
|
||||||
|
.leaflet-map-pane svg { z-index: 200; }
|
||||||
|
|
||||||
|
.leaflet-vml-shape {
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
.lvml {
|
||||||
|
behavior: url(#default#VML);
|
||||||
|
display: inline-block;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* control positioning */
|
||||||
|
|
||||||
|
.leaflet-control {
|
||||||
|
position: relative;
|
||||||
|
z-index: 800;
|
||||||
|
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.leaflet-top,
|
||||||
|
.leaflet-bottom {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1000;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.leaflet-top {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.leaflet-right {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
.leaflet-bottom {
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
.leaflet-left {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
.leaflet-control {
|
||||||
|
float: left;
|
||||||
|
clear: both;
|
||||||
|
}
|
||||||
|
.leaflet-right .leaflet-control {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
.leaflet-top .leaflet-control {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.leaflet-bottom .leaflet-control {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.leaflet-left .leaflet-control {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
.leaflet-right .leaflet-control {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* zoom and fade animations */
|
||||||
|
|
||||||
|
.leaflet-fade-anim .leaflet-popup {
|
||||||
|
opacity: 0;
|
||||||
|
-webkit-transition: opacity 0.2s linear;
|
||||||
|
-moz-transition: opacity 0.2s linear;
|
||||||
|
transition: opacity 0.2s linear;
|
||||||
|
}
|
||||||
|
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.leaflet-zoom-animated {
|
||||||
|
-webkit-transform-origin: 0 0;
|
||||||
|
-ms-transform-origin: 0 0;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
svg.leaflet-zoom-animated {
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||||
|
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||||
|
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||||
|
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||||
|
}
|
||||||
|
.leaflet-zoom-anim .leaflet-tile,
|
||||||
|
.leaflet-pan-anim .leaflet-tile {
|
||||||
|
-webkit-transition: none;
|
||||||
|
-moz-transition: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* cursors */
|
||||||
|
|
||||||
|
.leaflet-interactive {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.leaflet-grab {
|
||||||
|
cursor: -webkit-grab;
|
||||||
|
cursor: -moz-grab;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.leaflet-crosshair,
|
||||||
|
.leaflet-crosshair .leaflet-interactive {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
.leaflet-popup-pane,
|
||||||
|
.leaflet-control {
|
||||||
|
cursor: auto;
|
||||||
|
}
|
||||||
|
.leaflet-dragging .leaflet-grab,
|
||||||
|
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||||
|
.leaflet-dragging .leaflet-marker-draggable {
|
||||||
|
cursor: move;
|
||||||
|
cursor: -webkit-grabbing;
|
||||||
|
cursor: -moz-grabbing;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* marker & overlays interactivity */
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow,
|
||||||
|
.leaflet-image-layer,
|
||||||
|
.leaflet-pane > svg path,
|
||||||
|
.leaflet-tile-container {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-marker-icon.leaflet-interactive,
|
||||||
|
.leaflet-image-layer.leaflet-interactive,
|
||||||
|
.leaflet-pane > svg path.leaflet-interactive,
|
||||||
|
svg.leaflet-image-layer.leaflet-interactive path {
|
||||||
|
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* visual tweaks */
|
||||||
|
|
||||||
|
.leaflet-container {
|
||||||
|
background: #ddd;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.leaflet-container a {
|
||||||
|
color: #0078A8;
|
||||||
|
}
|
||||||
|
.leaflet-zoom-box {
|
||||||
|
border: 2px dotted #38f;
|
||||||
|
background: rgba(255,255,255,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* general typography */
|
||||||
|
.leaflet-container {
|
||||||
|
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* general toolbar styles */
|
||||||
|
|
||||||
|
.leaflet-bar {
|
||||||
|
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.leaflet-bar a {
|
||||||
|
background-color: #fff;
|
||||||
|
border-bottom: 1px solid #ccc;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
line-height: 26px;
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
.leaflet-bar a,
|
||||||
|
.leaflet-control-layers-toggle {
|
||||||
|
background-position: 50% 50%;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.leaflet-bar a:hover,
|
||||||
|
.leaflet-bar a:focus {
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
}
|
||||||
|
.leaflet-bar a:first-child {
|
||||||
|
border-top-left-radius: 4px;
|
||||||
|
border-top-right-radius: 4px;
|
||||||
|
}
|
||||||
|
.leaflet-bar a:last-child {
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.leaflet-bar a.leaflet-disabled {
|
||||||
|
cursor: default;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-touch .leaflet-bar a {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-bar a:first-child {
|
||||||
|
border-top-left-radius: 2px;
|
||||||
|
border-top-right-radius: 2px;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-bar a:last-child {
|
||||||
|
border-bottom-left-radius: 2px;
|
||||||
|
border-bottom-right-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* zoom control */
|
||||||
|
|
||||||
|
.leaflet-control-zoom-in,
|
||||||
|
.leaflet-control-zoom-out {
|
||||||
|
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||||
|
text-indent: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* layers control */
|
||||||
|
|
||||||
|
.leaflet-control-layers {
|
||||||
|
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-toggle {
|
||||||
|
background-image: url(images/layers.png);
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
.leaflet-retina .leaflet-control-layers-toggle {
|
||||||
|
background-image: url(images/layers-2x.png);
|
||||||
|
background-size: 26px 26px;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-control-layers-toggle {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers .leaflet-control-layers-list,
|
||||||
|
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-expanded {
|
||||||
|
padding: 6px 10px 6px 6px;
|
||||||
|
color: #333;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-scrollbar {
|
||||||
|
overflow-y: scroll;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding-right: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-selector {
|
||||||
|
margin-top: 2px;
|
||||||
|
position: relative;
|
||||||
|
top: 1px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-size: 1.08333em;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-separator {
|
||||||
|
height: 0;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
margin: 5px -10px 5px -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Default icon URLs */
|
||||||
|
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||||
|
background-image: url(images/marker-icon.png);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* attribution and scale controls */
|
||||||
|
|
||||||
|
.leaflet-container .leaflet-control-attribution {
|
||||||
|
background: #fff;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.leaflet-control-attribution,
|
||||||
|
.leaflet-control-scale-line {
|
||||||
|
padding: 0 5px;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.leaflet-control-attribution a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.leaflet-control-attribution a:hover,
|
||||||
|
.leaflet-control-attribution a:focus {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.leaflet-attribution-flag {
|
||||||
|
display: inline !important;
|
||||||
|
vertical-align: baseline !important;
|
||||||
|
width: 1em;
|
||||||
|
height: 0.6669em;
|
||||||
|
}
|
||||||
|
.leaflet-left .leaflet-control-scale {
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-bottom .leaflet-control-scale {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-control-scale-line {
|
||||||
|
border: 2px solid #777;
|
||||||
|
border-top: none;
|
||||||
|
line-height: 1.1;
|
||||||
|
padding: 2px 5px 1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
-moz-box-sizing: border-box;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
text-shadow: 1px 1px #fff;
|
||||||
|
}
|
||||||
|
.leaflet-control-scale-line:not(:first-child) {
|
||||||
|
border-top: 2px solid #777;
|
||||||
|
border-bottom: none;
|
||||||
|
margin-top: -2px;
|
||||||
|
}
|
||||||
|
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||||
|
border-bottom: 2px solid #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-touch .leaflet-control-attribution,
|
||||||
|
.leaflet-touch .leaflet-control-layers,
|
||||||
|
.leaflet-touch .leaflet-bar {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-control-layers,
|
||||||
|
.leaflet-touch .leaflet-bar {
|
||||||
|
border: 2px solid rgba(0,0,0,0.2);
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* popup */
|
||||||
|
|
||||||
|
.leaflet-popup {
|
||||||
|
position: absolute;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.leaflet-popup-content-wrapper {
|
||||||
|
padding: 1px;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.leaflet-popup-content {
|
||||||
|
margin: 13px 24px 13px 20px;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-size: 13px;
|
||||||
|
font-size: 1.08333em;
|
||||||
|
min-height: 1px;
|
||||||
|
}
|
||||||
|
.leaflet-popup-content p {
|
||||||
|
margin: 17px 0;
|
||||||
|
margin: 1.3em 0;
|
||||||
|
}
|
||||||
|
.leaflet-popup-tip-container {
|
||||||
|
width: 40px;
|
||||||
|
height: 20px;
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
margin-top: -1px;
|
||||||
|
margin-left: -20px;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.leaflet-popup-tip {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
padding: 1px;
|
||||||
|
|
||||||
|
margin: -10px auto 0;
|
||||||
|
pointer-events: auto;
|
||||||
|
|
||||||
|
-webkit-transform: rotate(45deg);
|
||||||
|
-moz-transform: rotate(45deg);
|
||||||
|
-ms-transform: rotate(45deg);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
.leaflet-popup-content-wrapper,
|
||||||
|
.leaflet-popup-tip {
|
||||||
|
background: white;
|
||||||
|
color: #333;
|
||||||
|
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.leaflet-container a.leaflet-popup-close-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
border: none;
|
||||||
|
text-align: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||||
|
color: #757575;
|
||||||
|
text-decoration: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||||
|
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||||
|
color: #585858;
|
||||||
|
}
|
||||||
|
.leaflet-popup-scrolled {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||||
|
-ms-zoom: 1;
|
||||||
|
}
|
||||||
|
.leaflet-oldie .leaflet-popup-tip {
|
||||||
|
width: 24px;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||||
|
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-oldie .leaflet-control-zoom,
|
||||||
|
.leaflet-oldie .leaflet-control-layers,
|
||||||
|
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||||
|
.leaflet-oldie .leaflet-popup-tip {
|
||||||
|
border: 1px solid #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* div icon */
|
||||||
|
|
||||||
|
.leaflet-div-icon {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Tooltip */
|
||||||
|
/* Base styles for the element that has a tooltip */
|
||||||
|
.leaflet-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
padding: 6px;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #fff;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #222;
|
||||||
|
white-space: nowrap;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.leaflet-tooltip.leaflet-interactive {
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-top:before,
|
||||||
|
.leaflet-tooltip-bottom:before,
|
||||||
|
.leaflet-tooltip-left:before,
|
||||||
|
.leaflet-tooltip-right:before {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
border: 6px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Directions */
|
||||||
|
|
||||||
|
.leaflet-tooltip-bottom {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-top {
|
||||||
|
margin-top: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-bottom:before,
|
||||||
|
.leaflet-tooltip-top:before {
|
||||||
|
left: 50%;
|
||||||
|
margin-left: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-top:before {
|
||||||
|
bottom: 0;
|
||||||
|
margin-bottom: -12px;
|
||||||
|
border-top-color: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-bottom:before {
|
||||||
|
top: 0;
|
||||||
|
margin-top: -12px;
|
||||||
|
margin-left: -6px;
|
||||||
|
border-bottom-color: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-left {
|
||||||
|
margin-left: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-right {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-left:before,
|
||||||
|
.leaflet-tooltip-right:before {
|
||||||
|
top: 50%;
|
||||||
|
margin-top: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-left:before {
|
||||||
|
right: 0;
|
||||||
|
margin-right: -12px;
|
||||||
|
border-left-color: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-right:before {
|
||||||
|
left: 0;
|
||||||
|
margin-left: -12px;
|
||||||
|
border-right-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Printing */
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
/* Prevent printers from removing background-images of controls. */
|
||||||
|
.leaflet-control {
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/static/leaflet/leaflet.js
Normal file
1
src/static/leaflet/leaflet.js.map
Normal file
@@ -10,10 +10,32 @@
|
|||||||
"theme_color": "#018133ff",
|
"theme_color": "#018133ff",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/static/mobile_icon.png",
|
"src": "/static/images/mobile_icon.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any maskable"
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/images/mobile_icon.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"screenshots": [
|
||||||
|
{
|
||||||
|
"src": "/static/images/screenshot-mobile.png",
|
||||||
|
"sizes": "390x844",
|
||||||
|
"type": "image/png",
|
||||||
|
"form_factor": "narrow",
|
||||||
|
"label": "Lagomare Gates login screen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/images/screenshot-wide.png",
|
||||||
|
"sizes": "1280x800",
|
||||||
|
"type": "image/png",
|
||||||
|
"form_factor": "wide",
|
||||||
|
"label": "Lagomare Gates login screen"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"lang": "en"
|
"lang": "en"
|
||||||
|
|||||||
@@ -105,8 +105,7 @@ label {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.gate-btn .icon { font-size: 1.8rem; line-height: 1; }
|
.gate-btn .icon { font-size: 1.8rem; line-height: 1; }
|
||||||
.gate-btn.car { background: var(--primary); color: #fff; }
|
.gate-btn { background: var(--primary); color: #fff; }
|
||||||
.gate-btn.pedestrian { background: var(--green); color: #fff; }
|
|
||||||
.gate-btn:not(:disabled):active { transform: scale(.94); }
|
.gate-btn:not(:disabled):active { transform: scale(.94); }
|
||||||
.gate-btn:disabled { opacity: .55; cursor: not-allowed; }
|
.gate-btn:disabled { opacity: .55; cursor: not-allowed; }
|
||||||
|
|
||||||
@@ -147,8 +146,8 @@ label {
|
|||||||
.table-wrap { overflow-x: auto; }
|
.table-wrap { overflow-x: auto; }
|
||||||
table { width: 100%; border-collapse: collapse; font-size: .9rem; }
|
table { width: 100%; border-collapse: collapse; font-size: .9rem; }
|
||||||
th { background: var(--surface2); color: var(--text-muted); font-weight: 600;
|
th { background: var(--surface2); color: var(--text-muted); font-weight: 600;
|
||||||
text-align: left; padding: .65rem 1rem; border-bottom: 1px solid var(--border); }
|
text-align: left; padding: .55rem .75rem; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||||
td { padding: .65rem 1rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
td { padding: .55rem .75rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||||||
tr:last-child td { border-bottom: none; }
|
tr:last-child td { border-bottom: none; }
|
||||||
|
|
||||||
/* ── Badges ────────────────────────────────────────────────────────────────── */
|
/* ── Badges ────────────────────────────────────────────────────────────────── */
|
||||||
@@ -189,8 +188,7 @@ tr:last-child td { border-bottom: none; }
|
|||||||
|
|
||||||
/* ── Error text ────────────────────────────────────────────────────────────── */
|
/* ── Error text ────────────────────────────────────────────────────────────── */
|
||||||
.error-msg { color: var(--red); font-size: .85rem; margin-top: .5rem; }
|
.error-msg { color: var(--red); font-size: .85rem; margin-top: .5rem; }
|
||||||
|
/* ── PWA install banner ──────────────────────────────────────────────── */
|
||||||
/* ── PWA install banner ────────────────────────────────────────────────────── */
|
|
||||||
.install-banner {
|
.install-banner {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 1rem;
|
bottom: 1rem;
|
||||||
@@ -252,4 +250,4 @@ tr:last-child td { border-bottom: none; }
|
|||||||
@keyframes banner-in {
|
@keyframes banner-in {
|
||||||
from { opacity: 0; transform: translateX(-50%) translateY(1rem); }
|
from { opacity: 0; transform: translateX(-50%) translateY(1rem); }
|
||||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/* Service worker - Lagomare Gates */
|
/* Service worker - Lagomare Gates */
|
||||||
const CACHE = "lagomare-gates-v1";
|
const CACHE = "lagomare-gates-__STATIC_HASH__";
|
||||||
const PRECACHE = ["/", "/static/style.css", "/static/app.js", "/static/logo.svg", "/static/mobile_icon.png", "/manifest.json"];
|
const PRECACHE = ["/static/style.css", "/static/app.js", "/static/images/logo.svg", "/static/images/mobile_icon.png", "/manifest.json"];
|
||||||
|
|
||||||
self.addEventListener("install", event => {
|
self.addEventListener("install", event => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
@@ -17,8 +17,13 @@ self.addEventListener("activate", event => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener("fetch", event => {
|
self.addEventListener("fetch", event => {
|
||||||
// Let API calls always go to the network
|
// Let API calls and map tiles always go to the network
|
||||||
if (event.request.url.includes("/api/")) return;
|
if (event.request.url.includes("/api/")) return;
|
||||||
|
if (event.request.url.includes("tile.openstreetmap.org")) return;
|
||||||
|
|
||||||
|
// Navigation requests (page loads, QR code opens) must always hit the network
|
||||||
|
// so query parameters like ?k=CODE are preserved for app.js
|
||||||
|
if (event.request.mode === "navigate") return;
|
||||||
|
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
caches.match(event.request).then(cached => cached || fetch(event.request))
|
caches.match(event.request).then(cached => cached || fetch(event.request))
|
||||||
|
|||||||