diff --git a/app/ui/app.py b/app/ui/app.py index a18273e..c61e61d 100644 --- a/app/ui/app.py +++ b/app/ui/app.py @@ -27,6 +27,7 @@ from app.modules.notice_cases.ui import router as notice_cases_router from app.modules.wizards.ui import router as wizards_ui_router from app.modules.system_admin_dashboard.ui import router as system_admin_dashboard_router from app.ui.routes.auth import router as auth_router +from app.ui.routes.pwa import router as pwa_router from app.modules.workspace_navigation.ui import router as workspace_navigation_router from app.modules.firm_admin_dashboard.ui import router as firm_admin_dashboard_router from app.modules.aqmm_dashboard.ui import router as aqmm_dashboard_router @@ -38,6 +39,7 @@ def mount_ui(app: FastAPI) -> None: app.mount("/storage", StaticFiles(directory="/app/data/storage"), name="storage") app.include_router(marketplace_public_router) app.include_router(auth_router) + app.include_router(pwa_router) app.include_router(firm_admin_dashboard_router) app.include_router(system_settings_router) app.include_router(email_integration_router) diff --git a/app/ui/routes/auth.py b/app/ui/routes/auth.py index 4263ef1..7232d53 100644 --- a/app/ui/routes/auth.py +++ b/app/ui/routes/auth.py @@ -1,4 +1,4 @@ -from __future__ import annotations +from __future__ import annotations from datetime import datetime, timezone, timedelta @@ -26,6 +26,21 @@ from app.modules.email_integration.services import send_auth_otp_email, send_pas router = APIRouter() +PENDING_POST_LOGIN_REDIRECT_KEY = "pending_post_login_redirect" +SAFE_POST_LOGIN_REDIRECTS = { + "/mobile/attendance", + "/employee/attendance", + "/employee/dashboard", +} + + +def _consume_safe_post_login_redirect(request: Request) -> str | None: + value = request.session.pop(PENDING_POST_LOGIN_REDIRECT_KEY, None) + value = (value or "").strip() + if value in SAFE_POST_LOGIN_REDIRECTS: + return value + return None + def _dev_otp_print_enabled() -> bool: settings = get_settings() @@ -459,9 +474,11 @@ def login_submit( if active_financial_year: request.session["active_financial_year"] = active_financial_year request.session["must_change_password"] = must_change_password - request.session["post_login_redirect"] = _post_login_redirect( + default_post_login_redirect = _post_login_redirect( must_change_password, permissions, roles ) + pending_post_login_redirect = None if must_change_password else _consume_safe_post_login_redirect(request) + request.session["post_login_redirect"] = pending_post_login_redirect or default_post_login_redirect if _otp_required(bs, roles): code = start_otp(request) diff --git a/app/ui/routes/pwa.py b/app/ui/routes/pwa.py new file mode 100644 index 0000000..fe0a283 --- /dev/null +++ b/app/ui/routes/pwa.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi import APIRouter, Request +from fastapi.responses import FileResponse, JSONResponse, RedirectResponse + +from app.core.db.common import CommonSessionLocal +from app.core.security.session_auth import SESSION_USER_ID_KEY, get_current_user +from app.modules.core.rbac.permission_guard import require_permission + +router = APIRouter(tags=["pwa-ui"]) + +PWA_REDIRECT_SESSION_KEY = "pending_post_login_redirect" +MOBILE_ATTENDANCE_PATH = "/mobile/attendance" +PWA_STATIC_ROOT = Path("app/ui/static/pwa") + + +def _safe_mobile_redirect_path(value: str | None) -> str | None: + value = (value or "").strip() + if value in {MOBILE_ATTENDANCE_PATH, "/employee/attendance", "/employee/dashboard"}: + return value + return None + + +def _remember_mobile_attendance_redirect(request: Request) -> None: + request.session[PWA_REDIRECT_SESSION_KEY] = MOBILE_ATTENDANCE_PATH + + +@router.get("/manifest.webmanifest", include_in_schema=False) +def pwa_manifest(): + return JSONResponse( + { + "name": "ARRR ERP Attendance", + "short_name": "ARRR ERP", + "description": "Mobile attendance access for ARRR ERP staff users.", + "id": "/mobile/attendance", + "start_url": "/mobile/attendance", + "scope": "/", + "display": "standalone", + "orientation": "portrait", + "theme_color": "#1e3a8a", + "background_color": "#f8fafc", + "categories": ["business", "productivity"], + "icons": [ + { + "src": "/static/pwa/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any", + }, + { + "src": "/static/pwa/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any", + }, + { + "src": "/static/pwa/icons/maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable", + }, + ], + "shortcuts": [ + { + "name": "Mark Attendance", + "short_name": "Attendance", + "description": "Open mobile attendance punch screen.", + "url": MOBILE_ATTENDANCE_PATH, + "icons": [ + { + "src": "/static/pwa/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + } + ], + } + ], + }, + media_type="application/manifest+json", + headers={"Cache-Control": "no-cache"}, + ) + + +@router.get("/sw.js", include_in_schema=False) +def pwa_service_worker(): + return FileResponse( + PWA_STATIC_ROOT / "sw.js", + media_type="application/javascript", + headers={ + "Cache-Control": "no-cache", + "Service-Worker-Allowed": "/", + }, + ) + + +@router.get("/mobile", include_in_schema=False) +def mobile_root(request: Request): + return RedirectResponse(url=MOBILE_ATTENDANCE_PATH, status_code=303) + + +@router.get(MOBILE_ATTENDANCE_PATH, include_in_schema=False) +def mobile_attendance(request: Request): + if not request.session.get(SESSION_USER_ID_KEY): + _remember_mobile_attendance_redirect(request) + return RedirectResponse(url="/login", status_code=303) + + if not request.session.get("otp_verified", False): + _remember_mobile_attendance_redirect(request) + return RedirectResponse(url="/otp", status_code=303) + + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + request.session.clear() + _remember_mobile_attendance_redirect(request) + return RedirectResponse(url="/login", status_code=303) + try: + require_permission(db, current_user, "employees.attendance.view_self") + except Exception: + return RedirectResponse(url="/employee/dashboard", status_code=303) + return RedirectResponse(url="/employee/attendance", status_code=303) + finally: + db.close() diff --git a/app/ui/static/pwa/icons/icon-192.png b/app/ui/static/pwa/icons/icon-192.png new file mode 100644 index 0000000..7d3206d Binary files /dev/null and b/app/ui/static/pwa/icons/icon-192.png differ diff --git a/app/ui/static/pwa/icons/icon-512.png b/app/ui/static/pwa/icons/icon-512.png new file mode 100644 index 0000000..5f1fd69 Binary files /dev/null and b/app/ui/static/pwa/icons/icon-512.png differ diff --git a/app/ui/static/pwa/icons/maskable-512.png b/app/ui/static/pwa/icons/maskable-512.png new file mode 100644 index 0000000..5cf735f Binary files /dev/null and b/app/ui/static/pwa/icons/maskable-512.png differ diff --git a/app/ui/static/pwa/sw.js b/app/ui/static/pwa/sw.js new file mode 100644 index 0000000..922d6b9 --- /dev/null +++ b/app/ui/static/pwa/sw.js @@ -0,0 +1,42 @@ +const CACHE_VERSION = 'arrr-erp-pwa-v1'; +const CORE_ASSETS = [ + '/manifest.webmanifest', + '/static/css/theme_tokens.css', + '/static/pwa/icons/icon-192.png', + '/static/pwa/icons/icon-512.png' +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_VERSION).then((cache) => cache.addAll(CORE_ASSETS)).catch(() => undefined) + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== CACHE_VERSION).map((key) => caches.delete(key)))) + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', (event) => { + const request = event.request; + if (request.method !== 'GET') return; + + const url = new URL(request.url); + if (url.origin !== self.location.origin) return; + + if (url.pathname.startsWith('/static/')) { + event.respondWith( + caches.match(request).then((cached) => cached || fetch(request).then((response) => { + const copy = response.clone(); + caches.open(CACHE_VERSION).then((cache) => cache.put(request, copy)).catch(() => undefined); + return response; + })) + ); + return; + } + + event.respondWith(fetch(request).catch(() => caches.match('/static/css/theme_tokens.css'))); +}); diff --git a/app/ui/templates/base/layout.html b/app/ui/templates/base/layout.html index 87163d3..8f2acb1 100644 --- a/app/ui/templates/base/layout.html +++ b/app/ui/templates/base/layout.html @@ -6,6 +6,12 @@ {% set __title_user = current_user if current_user is defined else None %} {% set __firm_branding = get_current_firm_branding(request, __title_user) %} + + + + + + {% if __firm_branding.favicon_url %}{% endif %} {% set __title_auth = __title_user and request.session.get("otp_verified", False) %} {% if __title_auth %} @@ -502,6 +508,17 @@ {{ flash }} {% endif %} + {% if current_path == '/employee/attendance' or current_path == '/mobile/attendance' %} +
+ {% endif %} {% block content %}{% endblock %} @@ -627,5 +644,37 @@ {% endif %} + +