From 6f90bd2891b2e2959a40154b2557248d55a35e9c Mon Sep 17 00:00:00 2001 From: Ettore <=> Date: Fri, 22 May 2026 23:50:05 +0200 Subject: [PATCH] Add button for home screen add instructions --- README.md | 22 ++++++++- src/main.py | 7 ++- src/static/app.js | 60 ++++++++++++++---------- src/static/images/add_to_home_screen.svg | 1 + src/static/index.html | 7 ++- src/static/style.css | 5 +- 6 files changed, 69 insertions(+), 33 deletions(-) create mode 100644 src/static/images/add_to_home_screen.svg diff --git a/README.md b/README.md index df22786..50ca83e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ A web-based gate access management and control system. Authorized users can remo ## Features -- **Keypass authentication** — users authenticate with an access code; each keypass can have a per-gate allowlist and an optional expiration date +- **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 @@ -93,7 +94,7 @@ data/ |---|---|---| | GET | `/api/admin/keypasses` | List all keypasses | | POST | `/api/admin/keypasses` | Create a keypass | -| PATCH | `/api/admin/keypasses/{kp_id}` | Update 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 | @@ -240,6 +241,23 @@ The Shelly server URI and auth key are stored encrypted. Configure them under ** **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: diff --git a/src/main.py b/src/main.py index bf1be9d..4cf34ca 100644 --- a/src/main.py +++ b/src/main.py @@ -103,8 +103,11 @@ async def _security_headers(request: Request, call_next) -> Response: 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';" - " img-src 'self' data: blob: https://*.tile.openstreetmap.org;" + "default-src 'self';" + " script-src 'self' https://cdn.jsdelivr.net;" + " style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com;" + " font-src 'self' https://fonts.gstatic.com;" + " img-src 'self' data: blob: https://*.tile.openstreetmap.org https://cdn.jsdelivr.net;" " connect-src 'self' https://*.tile.openstreetmap.org" ) return response diff --git a/src/static/app.js b/src/static/app.js index 328214f..dbe2a86 100644 --- a/src/static/app.js +++ b/src/static/app.js @@ -342,33 +342,43 @@ if ("serviceWorker" in navigator) { 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"; -let _deferredInstallPrompt = null; -window.addEventListener("beforeinstallprompt", (e) => { - e.preventDefault(); - if (sessionStorage.getItem(INSTALL_DISMISSED_KEY)) return; - _deferredInstallPrompt = e; - document.getElementById("install-banner").classList.remove("hidden"); -}); +if (_isInstalled) { + // Already installed — hide both + _installAppBtn.style.display = "none"; +} else if (typeof window.AddToHomeScreen === "function") { + const _addToHomeScreenInstance = window.AddToHomeScreen({ + appName: "Lagomare Gates", + appIconUrl: "/static/images/mobile_icon.png", + assetUrl: "https://cdn.jsdelivr.net/npm/pwa-add-to-homescreen@4/dist/assets/img/", + maxModalDisplayCount: -1, + displayOptions: { showMobile: true, showDesktop: true }, + allowClose: true, + showArrow: true, + }); -document.getElementById("install-btn").addEventListener("click", async () => { - const banner = document.getElementById("install-banner"); - banner.classList.add("hidden"); - if (!_deferredInstallPrompt) return; - _deferredInstallPrompt.prompt(); - await _deferredInstallPrompt.userChoice; - _deferredInstallPrompt = null; -}); + // Header button — always triggers the library UI + _installAppBtn.addEventListener("click", () => { + _addToHomeScreenInstance.show("en"); + }); -document.getElementById("install-dismiss").addEventListener("click", () => { - document.getElementById("install-banner").classList.add("hidden"); - sessionStorage.setItem(INSTALL_DISMISSED_KEY, "1"); - _deferredInstallPrompt = null; -}); + // Auto-show banner once per session + if (!sessionStorage.getItem(INSTALL_DISMISSED_KEY)) { + _installBanner.classList.remove("hidden"); + } -window.addEventListener("appinstalled", () => { - document.getElementById("install-banner").classList.add("hidden"); - _deferredInstallPrompt = null; -}); + document.getElementById("install-btn").addEventListener("click", () => { + _installBanner.classList.add("hidden"); + _addToHomeScreenInstance.show("en"); + }); + + document.getElementById("install-dismiss").addEventListener("click", () => { + _installBanner.classList.add("hidden"); + sessionStorage.setItem(INSTALL_DISMISSED_KEY, "1"); + }); +} diff --git a/src/static/images/add_to_home_screen.svg b/src/static/images/add_to_home_screen.svg new file mode 100644 index 0000000..1c8963f --- /dev/null +++ b/src/static/images/add_to_home_screen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/static/index.html b/src/static/index.html index ff0aa1c..31c25f2 100644 --- a/src/static/index.html +++ b/src/static/index.html @@ -12,6 +12,7 @@ +