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.mobile_tokens_heading") }}
+
+
{{ _("devices.mobile_tokens_description") }}
+
+
+
+
+
+
+
{{ _("devices.loading") }}
+
+
+
+
+
+
+
+
{{ _("devices.no_mobile_tokens") }}
+
{{ _("devices.no_mobile_tokens_help") }}
+
+
+
+
+
+
+
+
+
+ | {{ _("devices.col_device") }} |
+ {{ _("devices.col_token_prefix") }} |
+ {{ _("devices.col_created") }} |
+ {{ _("devices.col_last_used") }} |
+ {{ _("devices.col_status") }} |
+ {{ _("common.actions") }} |
+
+
+
+
+
+ |
+
+
+
+
+ |
+
+
+ |
+ |
+
+
+
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ _("devices.registered_devices_heading") }}
+
+
{{ _("devices.registered_devices_description") }}
+
+
+
+
+
+
+
{{ _("devices.loading") }}
+
+
+
+
+
+
+
+
{{ _("devices.no_devices") }}
+
{{ _("devices.no_devices_help") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ _("devices.col_last_seen") }}:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% 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)