diff --git a/app/api/api_tokens.py b/app/api/api_tokens.py index a53ba7f2..1beef61b 100644 --- a/app/api/api_tokens.py +++ b/app/api/api_tokens.py @@ -42,6 +42,9 @@ TOKEN_HASH_ITERATIONS = 100_000 #: PBKDF2 salt for API token hashing (not secret, but fixed for determinism). TOKEN_HASH_SALT = b"api-token-v1" +#: Name prefix used for tokens created by the mobile app flow. +MOBILE_TOKEN_PREFIX = "Mobile App" + # --------------------------------------------------------------------------- # Auth helper @@ -91,6 +94,20 @@ def hash_token(token: str) -> str: return dk.hex() +def _token_to_dict(t: ApiToken) -> dict[str, Any]: + """Convert an ``ApiToken`` ORM instance to a serialisable dict.""" + return { + "id": t.id, + "name": t.name, + "token_prefix": t.token_prefix, + "is_active": t.is_active, + "last_used_at": t.last_used_at, + "last_used_ip": t.last_used_ip, + "created_at": t.created_at, + "revoked_at": t.revoked_at, + } + + # --------------------------------------------------------------------------- # Pydantic schemas # --------------------------------------------------------------------------- @@ -177,21 +194,44 @@ async def list_tokens( owner_id: CurrentOwner, db: DbSession, ) -> list[dict[str, Any]]: - """List all API tokens for the authenticated user.""" - tokens = db.query(ApiToken).filter(ApiToken.owner_id == owner_id).order_by(ApiToken.created_at.desc()).all() - return [ - { - "id": t.id, - "name": t.name, - "token_prefix": t.token_prefix, - "is_active": t.is_active, - "last_used_at": t.last_used_at, - "last_used_ip": t.last_used_ip, - "created_at": t.created_at, - "revoked_at": t.revoked_at, - } - for t in tokens - ] + """List non-mobile API tokens for the authenticated user. + + Mobile tokens (whose names start with ``"Mobile App"``) are excluded + from this list; they are managed on the dedicated Devices page via + ``GET /api/api-tokens/mobile``. + """ + tokens = ( + db.query(ApiToken) + .filter( + ApiToken.owner_id == owner_id, + ~ApiToken.name.startswith(MOBILE_TOKEN_PREFIX), + ) + .order_by(ApiToken.created_at.desc()) + .all() + ) + return [_token_to_dict(t) for t in tokens] + + +@router.get("/mobile", response_model=list[TokenResponse]) +async def list_mobile_tokens( + owner_id: CurrentOwner, + db: DbSession, +) -> list[dict[str, Any]]: + """List mobile API tokens for the authenticated user. + + Returns tokens whose names start with ``"Mobile App"`` — these are + created via the mobile SSO flow or QR code login. + """ + tokens = ( + db.query(ApiToken) + .filter( + ApiToken.owner_id == owner_id, + ApiToken.name.startswith(MOBILE_TOKEN_PREFIX), + ) + .order_by(ApiToken.created_at.desc()) + .all() + ) + return [_token_to_dict(t) for t in tokens] @router.delete("/{token_id}", status_code=status.HTTP_200_OK) diff --git a/app/views/__init__.py b/app/views/__init__.py index 4d1cce5a..bb7153ba 100644 --- a/app/views/__init__.py +++ b/app/views/__init__.py @@ -10,6 +10,7 @@ from app.views.audit_logs import router as audit_logs_router from app.views.backup import router as backup_router from app.views.compliance import router as compliance_router from app.views.db_wizard import router as db_wizard_router +from app.views.devices import router as devices_router # Mobile devices dashboard from app.views.dropbox import router as dropbox_router from app.views.filemanager import router as filemanager_router @@ -69,3 +70,4 @@ router.include_router(scheduled_jobs_router) # Admin scheduled batch jobs router.include_router(audit_logs_router) # Comprehensive audit log viewer router.include_router(help_router) # Built-in help / How-To docs router.include_router(compliance_router) # Compliance templates dashboard +router.include_router(devices_router) # Mobile devices dashboard diff --git a/app/views/devices.py b/app/views/devices.py new file mode 100644 index 00000000..e8c346d1 --- /dev/null +++ b/app/views/devices.py @@ -0,0 +1,25 @@ +"""View route for the Devices management page. + +Renders the ``devices.html`` template where users can see their registered +mobile devices, mobile API tokens (created via the mobile SSO flow or QR +code login), and revoke access per-device. +""" + +import logging + +from fastapi import APIRouter, Request + +from app.views.base import require_login, templates + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.get("/devices", include_in_schema=False) +@require_login +async def devices_page(request: Request): + """Render the Devices management page.""" + return templates.TemplateResponse( + "devices.html", + {"request": request, "page_title": "Devices"}, + ) diff --git a/frontend/static/js/common.js b/frontend/static/js/common.js index 67219a11..1ec9ae6b 100644 --- a/frontend/static/js/common.js +++ b/frontend/static/js/common.js @@ -215,6 +215,9 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') { linksDiv.appendChild( _makeMenuLink('/api-tokens', 'fas fa-key text-yellow-500', window.__i18n.apiTokens || 'API Tokens', 'text-gray-700') ); + linksDiv.appendChild( + _makeMenuLink('/devices', 'fas fa-mobile-alt text-blue-500', window.__i18n.devices || 'Devices', 'text-gray-700') + ); linksDiv.appendChild( _makeMenuLink('/shared-links', 'fas fa-share-alt text-blue-400', window.__i18n.sharedLinks || 'Shared Links', 'text-gray-700') ); @@ -311,6 +314,18 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') { tokensLink.appendChild(document.createTextNode(window.__i18n.apiTokens || 'API Tokens')); mobileAuthSection.appendChild(tokensLink); + // Devices link + const devicesLink = document.createElement('a'); + devicesLink.href = '/devices'; + devicesLink.className = + 'flex items-center px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50'; + const devicesIcon = document.createElement('i'); + devicesIcon.className = 'fas fa-mobile-alt mr-2 text-blue-500'; + devicesIcon.setAttribute('aria-hidden', 'true'); + devicesLink.appendChild(devicesIcon); + devicesLink.appendChild(document.createTextNode(window.__i18n.devices || 'Devices')); + mobileAuthSection.appendChild(devicesLink); + // Shared Links link const sharedLinksLink = document.createElement('a'); sharedLinksLink.href = '/shared-links'; diff --git a/frontend/templates/base.html b/frontend/templates/base.html index d049cfe2..6f4e220e 100644 --- a/frontend/templates/base.html +++ b/frontend/templates/base.html @@ -560,6 +560,7 @@ profileSettings: {{ _("nav.profile_settings") | tojson }}, mySubscription: {{ _("nav.my_subscription") | tojson }}, apiTokens: {{ _("nav.api_tokens") | tojson }}, + devices: {{ _("nav.devices") | tojson }}, sharedLinks: {{ _("nav.shared_links") | tojson }}, signOut: {{ _("nav.sign_out") | tojson }}, logIn: {{ _("nav.login") | tojson }}, diff --git a/frontend/templates/devices.html b/frontend/templates/devices.html new file mode 100644 index 00000000..e8f170fd --- /dev/null +++ b/frontend/templates/devices.html @@ -0,0 +1,353 @@ +{% extends "base.html" %} + +{% block title %}{{ _("devices.page_title") }}{% endblock %} + +{% block content %} +
+ + +
+

+ + {{ _("devices.heading") }} +

+

+ {{ _("devices.intro") }} +

+
+ + +
+
+

+ {{ _("devices.mobile_tokens_heading") }} +

+

{{ _("devices.mobile_tokens_description") }}

+
+ + + + + + + + + + + + +
+ + +
+
+

+ {{ _("devices.registered_devices_heading") }} +

+

{{ _("devices.registered_devices_description") }}

+
+ + + + + + + + + + + + +
+ + +
+ + + {{ _("devices.qr_login_cta") }} + +
+ + + +
+ + +{% endblock %} diff --git a/frontend/translations/en.json b/frontend/translations/en.json index 3146e36c..09575938 100644 --- a/frontend/translations/en.json +++ b/frontend/translations/en.json @@ -608,6 +608,37 @@ "dashboard.title": "Dashboard", "dashboard.total_files": "Total Files", "dashboard.welcome": "Welcome to DocuElevate", + "devices.col_created": "Connected", + "devices.col_device": "Device", + "devices.col_last_ip": "Last IP", + "devices.col_last_seen": "Last Seen", + "devices.col_last_used": "Last Used", + "devices.col_platform": "Platform", + "devices.col_push_token": "Push Token", + "devices.col_status": "Status", + "devices.col_token_prefix": "Token Prefix", + "devices.confirm_deactivate_device": "Remove this device? It will stop receiving push notifications.", + "devices.confirm_revoke_token": "Revoke access for this device? It will need to log in again.", + "devices.deactivate_device": "Remove", + "devices.device_removed_success": "Device removed successfully.", + "devices.heading": "Mobile Devices", + "devices.intro": "Manage your mobile app connections and registered devices. You can revoke access for individual devices here.", + "devices.loading": "Loading devices…", + "devices.mobile_tokens_description": "These tokens were created when you logged in via the mobile app or scanned a QR code. Revoking a token will sign the device out.", + "devices.mobile_tokens_heading": "Mobile App Tokens", + "devices.no_devices": "No registered devices", + "devices.no_devices_help": "Install the DocuElevate mobile app and log in to register a device for push notifications.", + "devices.no_mobile_tokens": "No mobile app tokens", + "devices.no_mobile_tokens_help": "Log in via the mobile app or scan a QR code to create a mobile token.", + "devices.page_title": "Devices – DocuElevate", + "devices.qr_login_cta": "Connect a new device via QR code", + "devices.registered_devices_description": "Devices registered for push notifications from the DocuElevate mobile app.", + "devices.registered_devices_heading": "Registered Devices", + "devices.revoke_token": "Revoke", + "devices.status_active": "Active", + "devices.status_inactive": "Inactive", + "devices.status_revoked": "Revoked", + "devices.token_revoked_success": "Device token revoked successfully.", "duplicates.file_id_label": "File ID", "duplicates.file_id_placeholder": "e.g. 42", "duplicates.find_btn": "Find", @@ -1152,6 +1183,7 @@ "nav.dark_mode": "Dark Mode", "nav.dashboard": "Dashboard", "nav.developer_docs": "Developer Docs", + "nav.devices": "Devices", "nav.duplicates": "Duplicates", "nav.file_manager": "File Manager", "nav.files": "Files", @@ -1465,25 +1497,6 @@ "qr_login.step_3": "Point your camera at this QR code", "qr_login.subtitle": "Log in to the mobile app by scanning a QR code from this page.", "qr_login.time_remaining": "Expires in {seconds} seconds", - "sessions.active_sessions": "Active Sessions", - "sessions.confirm_revoke_all": "This will log you out of all other devices and browsers, and revoke all API tokens. Continue?", - "sessions.confirm_revoke_one": "Are you sure you want to end this session?", - "sessions.current_session": "This device", - "sessions.device_info": "Device", - "sessions.expires": "Expires", - "sessions.ip_address": "IP Address", - "sessions.last_active": "Last active", - "sessions.log_off_everywhere": "Log Off All Other Sessions", - "sessions.log_off_everywhere_desc": "End all other browser sessions and revoke all API tokens. Your current session will remain active.", - "sessions.no_other_sessions": "No other active sessions found.", - "sessions.qr_login_link": "Log in on mobile via QR code", - "sessions.revoke": "End Session", - "sessions.revoked_all_success": "All other sessions have been ended.", - "sessions.revoked_success": "Session ended successfully.", - "sessions.security_heading": "Security & Sessions", - "sessions.security_subtitle": "Manage your active sessions across devices and browsers.", - "sessions.session_lifetime": "Session lifetime: {days} days", - "sessions.started": "Started", "queue.active_tasks": "Active Tasks", "queue.auto_refresh_1": "Auto-refreshes every", "queue.auto_refresh_2": "seconds", @@ -1545,6 +1558,25 @@ "search.saved_label": "Saved Searches", "search.saved_loading": "Loading...", "search.title": "Search Documents", + "sessions.active_sessions": "Active Sessions", + "sessions.confirm_revoke_all": "This will log you out of all other devices and browsers, and revoke all API tokens. Continue?", + "sessions.confirm_revoke_one": "Are you sure you want to end this session?", + "sessions.current_session": "This device", + "sessions.device_info": "Device", + "sessions.expires": "Expires", + "sessions.ip_address": "IP Address", + "sessions.last_active": "Last active", + "sessions.log_off_everywhere": "Log Off All Other Sessions", + "sessions.log_off_everywhere_desc": "End all other browser sessions and revoke all API tokens. Your current session will remain active.", + "sessions.no_other_sessions": "No other active sessions found.", + "sessions.qr_login_link": "Log in on mobile via QR code", + "sessions.revoke": "End Session", + "sessions.revoked_all_success": "All other sessions have been ended.", + "sessions.revoked_success": "Session ended successfully.", + "sessions.security_heading": "Security & Sessions", + "sessions.security_subtitle": "Manage your active sessions across devices and browsers.", + "sessions.session_lifetime": "Session lifetime: {days} days", + "sessions.started": "Started", "settings.audit_log_btn": "Audit Log", "settings.autocomplete_hint": "Type to search known values, or enter any custom value.", "settings.autocomplete_no_matches": "No matches — you can still type a custom value", diff --git a/tests/test_devices_page.py b/tests/test_devices_page.py new file mode 100644 index 00000000..106cba0c --- /dev/null +++ b/tests/test_devices_page.py @@ -0,0 +1,201 @@ +"""Tests for the Devices page and mobile token filtering (app/api/api_tokens.py mobile endpoint). + +These tests validate: +- ``GET /api/api-tokens/mobile`` returns only mobile tokens +- ``GET /api/api-tokens/`` excludes mobile tokens +- ``GET /devices`` renders the devices page +""" + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from app.database import Base, get_db +from app.models import ApiToken + +# --------------------------------------------------------------------------- +# Test data +# --------------------------------------------------------------------------- + +_OWNER = "devices_user@example.com" +_OTHER_OWNER = "other_devices@example.com" + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def dev_engine(): + """In-memory SQLite engine.""" + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + yield engine + Base.metadata.drop_all(bind=engine) + + +@pytest.fixture() +def dev_session(dev_engine): + """DB session scoped to one test.""" + Session = sessionmaker(bind=dev_engine) + session = Session() + yield session + session.close() + + +def _make_client(dev_engine, owner_id: str = _OWNER) -> TestClient: + """Return a TestClient with *owner_id* injected as the authenticated user.""" + from app.api.api_tokens import _get_owner_id + from app.main import app + + Session = sessionmaker(bind=dev_engine) + + def _override_get_db(): + session = Session() + try: + yield session + finally: + session.close() + + def _override_owner(): + return owner_id + + app.dependency_overrides[get_db] = _override_get_db + app.dependency_overrides[_get_owner_id] = _override_owner + + client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False) + return client + + +def _cleanup(app): + """Remove dependency overrides after test.""" + app.dependency_overrides.clear() + + +def _seed_tokens(session, owner_id: str = _OWNER): + """Create a mix of regular and mobile tokens for testing.""" + from app.api.api_tokens import generate_api_token, hash_token + + tokens = [] + # Regular API tokens + for name in ["CI Pipeline", "Webhook Upload"]: + pt = generate_api_token() + t = ApiToken(owner_id=owner_id, name=name, token_hash=hash_token(pt), token_prefix=pt[:12]) + session.add(t) + tokens.append(t) + + # Mobile tokens (various naming patterns) + for name in [ + "Mobile App – iPhone 15 Pro", + "Mobile App (QR) – Christian's iPad", + "Mobile App", + ]: + pt = generate_api_token() + t = ApiToken(owner_id=owner_id, name=name, token_hash=hash_token(pt), token_prefix=pt[:12]) + session.add(t) + tokens.append(t) + + session.commit() + return tokens + + +# --------------------------------------------------------------------------- +# Tests – Mobile Token Filtering +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestMobileTokenFiltering: + """Tests for GET /api/api-tokens/mobile and filtering from GET /api/api-tokens/.""" + + def test_list_mobile_tokens_returns_only_mobile(self, dev_engine, dev_session): + """GET /api/api-tokens/mobile should only return tokens starting with 'Mobile App'.""" + _seed_tokens(dev_session) + client = _make_client(dev_engine) + try: + res = client.get("/api/api-tokens/mobile") + assert res.status_code == 200 + data = res.json() + assert len(data) == 3 + for t in data: + assert t["name"].startswith("Mobile App") + finally: + _cleanup(client.app) + + def test_list_regular_tokens_excludes_mobile(self, dev_engine, dev_session): + """GET /api/api-tokens/ should NOT return tokens starting with 'Mobile App'.""" + _seed_tokens(dev_session) + client = _make_client(dev_engine) + try: + res = client.get("/api/api-tokens/") + assert res.status_code == 200 + data = res.json() + assert len(data) == 2 + for t in data: + assert not t["name"].startswith("Mobile App") + finally: + _cleanup(client.app) + + def test_list_mobile_tokens_empty(self, dev_engine): + """GET /api/api-tokens/mobile returns [] when no mobile tokens exist.""" + client = _make_client(dev_engine) + try: + res = client.get("/api/api-tokens/mobile") + assert res.status_code == 200 + assert res.json() == [] + finally: + _cleanup(client.app) + + def test_list_mobile_tokens_isolation(self, dev_engine, dev_session): + """Mobile tokens for other users should not appear.""" + _seed_tokens(dev_session, owner_id=_OTHER_OWNER) + client = _make_client(dev_engine, owner_id=_OWNER) + try: + res = client.get("/api/api-tokens/mobile") + assert res.status_code == 200 + assert res.json() == [] + finally: + _cleanup(client.app) + + def test_mobile_token_revoke_via_api_tokens_endpoint(self, dev_engine, dev_session): + """Mobile tokens can still be revoked via DELETE /api/api-tokens/{id}.""" + tokens = _seed_tokens(dev_session) + mobile_token = next(t for t in tokens if t.name.startswith("Mobile App")) + client = _make_client(dev_engine) + try: + res = client.delete(f"/api/api-tokens/{mobile_token.id}") + assert res.status_code == 200 + # Verify it's gone from mobile list + res2 = client.get("/api/api-tokens/mobile") + active_names = [t["name"] for t in res2.json() if t["is_active"]] + assert mobile_token.name not in active_names + finally: + _cleanup(client.app) + + +# --------------------------------------------------------------------------- +# Tests – Devices Page View +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestDevicesPageView: + """Tests for GET /devices page rendering.""" + + def test_devices_page_renders(self, dev_engine): + """GET /devices should return 200 with the devices template.""" + from app.views.devices import router as _ # noqa: F401 – ensures route is registered + + client = _make_client(dev_engine) + try: + res = client.get("/devices") + assert res.status_code == 200 + assert "devices.heading" in res.text or "Mobile Devices" in res.text + finally: + _cleanup(client.app)