feat(ui): add dedicated Devices page for mobile tokens and registered devices
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
+55
-15
@@ -42,6 +42,9 @@ TOKEN_HASH_ITERATIONS = 100_000
|
|||||||
#: PBKDF2 salt for API token hashing (not secret, but fixed for determinism).
|
#: PBKDF2 salt for API token hashing (not secret, but fixed for determinism).
|
||||||
TOKEN_HASH_SALT = b"api-token-v1"
|
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
|
# Auth helper
|
||||||
@@ -91,6 +94,20 @@ def hash_token(token: str) -> str:
|
|||||||
return dk.hex()
|
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
|
# Pydantic schemas
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -177,21 +194,44 @@ async def list_tokens(
|
|||||||
owner_id: CurrentOwner,
|
owner_id: CurrentOwner,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""List all API tokens for the authenticated user."""
|
"""List non-mobile API tokens for the authenticated user.
|
||||||
tokens = db.query(ApiToken).filter(ApiToken.owner_id == owner_id).order_by(ApiToken.created_at.desc()).all()
|
|
||||||
return [
|
Mobile tokens (whose names start with ``"Mobile App"``) are excluded
|
||||||
{
|
from this list; they are managed on the dedicated Devices page via
|
||||||
"id": t.id,
|
``GET /api/api-tokens/mobile``.
|
||||||
"name": t.name,
|
"""
|
||||||
"token_prefix": t.token_prefix,
|
tokens = (
|
||||||
"is_active": t.is_active,
|
db.query(ApiToken)
|
||||||
"last_used_at": t.last_used_at,
|
.filter(
|
||||||
"last_used_ip": t.last_used_ip,
|
ApiToken.owner_id == owner_id,
|
||||||
"created_at": t.created_at,
|
~ApiToken.name.startswith(MOBILE_TOKEN_PREFIX),
|
||||||
"revoked_at": t.revoked_at,
|
)
|
||||||
}
|
.order_by(ApiToken.created_at.desc())
|
||||||
for t in tokens
|
.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)
|
@router.delete("/{token_id}", status_code=status.HTTP_200_OK)
|
||||||
|
|||||||
@@ -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.backup import router as backup_router
|
||||||
from app.views.compliance import router as compliance_router
|
from app.views.compliance import router as compliance_router
|
||||||
from app.views.db_wizard import router as db_wizard_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.dropbox import router as dropbox_router
|
||||||
from app.views.filemanager import router as filemanager_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(audit_logs_router) # Comprehensive audit log viewer
|
||||||
router.include_router(help_router) # Built-in help / How-To docs
|
router.include_router(help_router) # Built-in help / How-To docs
|
||||||
router.include_router(compliance_router) # Compliance templates dashboard
|
router.include_router(compliance_router) # Compliance templates dashboard
|
||||||
|
router.include_router(devices_router) # Mobile devices dashboard
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
)
|
||||||
@@ -215,6 +215,9 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
|
|||||||
linksDiv.appendChild(
|
linksDiv.appendChild(
|
||||||
_makeMenuLink('/api-tokens', 'fas fa-key text-yellow-500', window.__i18n.apiTokens || 'API Tokens', 'text-gray-700')
|
_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(
|
linksDiv.appendChild(
|
||||||
_makeMenuLink('/shared-links', 'fas fa-share-alt text-blue-400', window.__i18n.sharedLinks || 'Shared Links', 'text-gray-700')
|
_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'));
|
tokensLink.appendChild(document.createTextNode(window.__i18n.apiTokens || 'API Tokens'));
|
||||||
mobileAuthSection.appendChild(tokensLink);
|
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
|
// Shared Links link
|
||||||
const sharedLinksLink = document.createElement('a');
|
const sharedLinksLink = document.createElement('a');
|
||||||
sharedLinksLink.href = '/shared-links';
|
sharedLinksLink.href = '/shared-links';
|
||||||
|
|||||||
@@ -560,6 +560,7 @@
|
|||||||
profileSettings: {{ _("nav.profile_settings") | tojson }},
|
profileSettings: {{ _("nav.profile_settings") | tojson }},
|
||||||
mySubscription: {{ _("nav.my_subscription") | tojson }},
|
mySubscription: {{ _("nav.my_subscription") | tojson }},
|
||||||
apiTokens: {{ _("nav.api_tokens") | tojson }},
|
apiTokens: {{ _("nav.api_tokens") | tojson }},
|
||||||
|
devices: {{ _("nav.devices") | tojson }},
|
||||||
sharedLinks: {{ _("nav.shared_links") | tojson }},
|
sharedLinks: {{ _("nav.shared_links") | tojson }},
|
||||||
signOut: {{ _("nav.sign_out") | tojson }},
|
signOut: {{ _("nav.sign_out") | tojson }},
|
||||||
logIn: {{ _("nav.login") | tojson }},
|
logIn: {{ _("nav.login") | tojson }},
|
||||||
|
|||||||
@@ -0,0 +1,353 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ _("devices.page_title") }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div x-data="devicesPage()" x-init="init()" class="container mx-auto px-4 py-8 max-w-4xl">
|
||||||
|
|
||||||
|
<!-- ── Header ─────────────────────────────────────────────────────────── -->
|
||||||
|
<header class="mb-8">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<i class="fas fa-mobile-alt text-blue-500" aria-hidden="true"></i>
|
||||||
|
{{ _("devices.heading") }}
|
||||||
|
</h1>
|
||||||
|
<p class="mt-2 text-gray-600 dark:text-gray-400 text-sm leading-relaxed max-w-2xl">
|
||||||
|
{{ _("devices.intro") }}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ── Mobile App Tokens ──────────────────────────────────────────────── -->
|
||||||
|
<section class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden mb-6" aria-labelledby="mobile-tokens-heading">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 id="mobile-tokens-heading" class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
<i class="fas fa-key text-yellow-500 mr-2" aria-hidden="true"></i>{{ _("devices.mobile_tokens_heading") }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ _("devices.mobile_tokens_description") }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<template x-if="loadingTokens">
|
||||||
|
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<i class="fas fa-spinner fa-spin text-2xl mb-2" aria-hidden="true"></i>
|
||||||
|
<p class="text-sm">{{ _("devices.loading") }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<template x-if="!loadingTokens && mobileTokens.length === 0">
|
||||||
|
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<i class="fas fa-mobile-alt text-4xl mb-3 text-gray-300 dark:text-gray-600" aria-hidden="true"></i>
|
||||||
|
<p class="font-medium">{{ _("devices.no_mobile_tokens") }}</p>
|
||||||
|
<p class="text-sm mt-1">{{ _("devices.no_mobile_tokens_help") }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Tokens table -->
|
||||||
|
<template x-if="!loadingTokens && mobileTokens.length > 0">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm" aria-label="{{ _('devices.mobile_tokens_heading') }}">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-gray-50 dark:bg-gray-750 text-left">
|
||||||
|
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_device") }}</th>
|
||||||
|
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_token_prefix") }}</th>
|
||||||
|
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_created") }}</th>
|
||||||
|
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_last_used") }}</th>
|
||||||
|
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_status") }}</th>
|
||||||
|
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider sr-only">{{ _("common.actions") }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<template x-for="token in mobileTokens" :key="token.id">
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="fas fa-mobile-alt text-gray-400" aria-hidden="true"></i>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white" x-text="formatDeviceName(token.name)"></span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<code class="bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded text-xs font-mono" x-text="token.token_prefix + '…'"></code>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-gray-500 dark:text-gray-400" x-text="formatDate(token.created_at)"></td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-gray-500 dark:text-gray-400">
|
||||||
|
<span x-text="token.last_used_at ? formatDate(token.last_used_at) : '—'"></span>
|
||||||
|
<span x-show="token.last_used_ip" class="block text-xs text-gray-400 mt-0.5">
|
||||||
|
<i class="fas fa-globe mr-1" aria-hidden="true"></i><span x-text="token.last_used_ip"></span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||||
|
:class="token.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'"
|
||||||
|
x-text="token.is_active ? '{{ _('devices.status_active') }}' : '{{ _('devices.status_revoked') }}'"
|
||||||
|
></span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||||
|
<button
|
||||||
|
x-show="token.is_active"
|
||||||
|
type="button"
|
||||||
|
@click="revokeToken(token)"
|
||||||
|
:disabled="revokingToken === token.id"
|
||||||
|
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-red-600 hover:text-red-800
|
||||||
|
dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50 transition-colors"
|
||||||
|
style="min-height:36px; min-width:44px;"
|
||||||
|
:aria-label="'{{ _('devices.revoke_token') }} ' + token.name"
|
||||||
|
>
|
||||||
|
<i :class="revokingToken === token.id ? 'fas fa-spinner fa-spin' : 'fas fa-sign-out-alt'" class="mr-1" aria-hidden="true"></i>
|
||||||
|
{{ _("devices.revoke_token") }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<template x-if="tokenError">
|
||||||
|
<div class="m-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 p-3 rounded text-sm" role="alert">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-1" aria-hidden="true"></i>
|
||||||
|
<span x-text="tokenError"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Registered Devices (Push Notifications) ────────────────────────── -->
|
||||||
|
<section class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden mb-6" aria-labelledby="devices-heading">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 id="devices-heading" class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
<i class="fas fa-bell text-purple-500 mr-2" aria-hidden="true"></i>{{ _("devices.registered_devices_heading") }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ _("devices.registered_devices_description") }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<template x-if="loadingDevices">
|
||||||
|
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<i class="fas fa-spinner fa-spin text-2xl mb-2" aria-hidden="true"></i>
|
||||||
|
<p class="text-sm">{{ _("devices.loading") }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<template x-if="!loadingDevices && devices.length === 0">
|
||||||
|
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<i class="fas fa-bell-slash text-4xl mb-3 text-gray-300 dark:text-gray-600" aria-hidden="true"></i>
|
||||||
|
<p class="font-medium">{{ _("devices.no_devices") }}</p>
|
||||||
|
<p class="text-sm mt-1">{{ _("devices.no_devices_help") }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Devices list -->
|
||||||
|
<template x-if="!loadingDevices && devices.length > 0">
|
||||||
|
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<template x-for="device in devices" :key="device.id">
|
||||||
|
<div class="flex items-center justify-between px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
|
||||||
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
|
<i
|
||||||
|
:class="device.platform === 'ios' ? 'fab fa-apple' :
|
||||||
|
device.platform === 'android' ? 'fab fa-android text-green-500' :
|
||||||
|
'fas fa-globe'"
|
||||||
|
class="text-lg text-gray-400 flex-shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
></i>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||||
|
<span x-text="device.device_name || 'Unknown Device'"></span>
|
||||||
|
<span
|
||||||
|
class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||||
|
:class="device.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'"
|
||||||
|
x-text="device.is_active ? '{{ _('devices.status_active') }}' : '{{ _('devices.status_inactive') }}'"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 space-x-3 mt-0.5">
|
||||||
|
<span>
|
||||||
|
<i class="fas fa-microchip mr-1" aria-hidden="true"></i>
|
||||||
|
<span x-text="device.platform.charAt(0).toUpperCase() + device.platform.slice(1)"></span>
|
||||||
|
</span>
|
||||||
|
<span x-show="device.last_seen_at">
|
||||||
|
<i class="fas fa-clock mr-1" aria-hidden="true"></i>{{ _("devices.col_last_seen") }}:
|
||||||
|
<span x-text="formatDate(device.last_seen_at)"></span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<i class="fas fa-calendar mr-1" aria-hidden="true"></i>
|
||||||
|
<span x-text="formatDate(device.created_at)"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
x-show="device.is_active"
|
||||||
|
type="button"
|
||||||
|
@click="deactivateDevice(device)"
|
||||||
|
:disabled="deactivatingDevice === device.id"
|
||||||
|
class="flex-shrink-0 inline-flex items-center px-3 py-1.5 text-sm font-medium text-red-600 hover:text-red-800
|
||||||
|
dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50 transition-colors"
|
||||||
|
style="min-height:36px; min-width:44px;"
|
||||||
|
:aria-label="'{{ _('devices.deactivate_device') }} ' + (device.device_name || 'device')"
|
||||||
|
>
|
||||||
|
<i :class="deactivatingDevice === device.id ? 'fas fa-spinner fa-spin' : 'fas fa-trash-alt'" class="mr-1" aria-hidden="true"></i>
|
||||||
|
{{ _("devices.deactivate_device") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<template x-if="deviceError">
|
||||||
|
<div class="m-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 p-3 rounded text-sm" role="alert">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-1" aria-hidden="true"></i>
|
||||||
|
<span x-text="deviceError"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── QR Login CTA ───────────────────────────────────────────────────── -->
|
||||||
|
<div class="text-center">
|
||||||
|
<a
|
||||||
|
href="/qr-login"
|
||||||
|
class="inline-flex items-center px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium
|
||||||
|
rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"
|
||||||
|
style="min-height:44px;"
|
||||||
|
>
|
||||||
|
<i class="fas fa-qrcode mr-2" aria-hidden="true"></i>
|
||||||
|
{{ _("devices.qr_login_cta") }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Status banner ──────────────────────────────────────────────────── -->
|
||||||
|
<div
|
||||||
|
x-show="banner.visible"
|
||||||
|
x-transition
|
||||||
|
class="mt-6 rounded-lg p-3 text-sm"
|
||||||
|
:class="banner.error
|
||||||
|
? 'bg-red-50 dark:bg-red-900/30 text-red-800 dark:text-red-200 border border-red-300 dark:border-red-700'
|
||||||
|
: 'bg-green-50 dark:bg-green-900/30 text-green-800 dark:text-green-200 border border-green-300 dark:border-green-700'"
|
||||||
|
role="alert"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<span x-text="banner.message"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function devicesPage() {
|
||||||
|
const csrfToken = '{{ csrf_token | default("") }}';
|
||||||
|
return {
|
||||||
|
mobileTokens: [],
|
||||||
|
devices: [],
|
||||||
|
loadingTokens: true,
|
||||||
|
loadingDevices: true,
|
||||||
|
revokingToken: null,
|
||||||
|
deactivatingDevice: null,
|
||||||
|
tokenError: null,
|
||||||
|
deviceError: null,
|
||||||
|
banner: { visible: false, error: false, message: '' },
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await Promise.all([this.loadMobileTokens(), this.loadDevices()]);
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadMobileTokens() {
|
||||||
|
this.loadingTokens = true;
|
||||||
|
this.tokenError = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/api-tokens/mobile', {
|
||||||
|
headers: { 'X-CSRF-Token': csrfToken },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to load mobile tokens');
|
||||||
|
this.mobileTokens = await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
this.tokenError = e.message;
|
||||||
|
} finally {
|
||||||
|
this.loadingTokens = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadDevices() {
|
||||||
|
this.loadingDevices = true;
|
||||||
|
this.deviceError = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/mobile/devices', {
|
||||||
|
headers: { 'X-CSRF-Token': csrfToken },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to load devices');
|
||||||
|
this.devices = await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
this.deviceError = e.message;
|
||||||
|
} finally {
|
||||||
|
this.loadingDevices = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async revokeToken(token) {
|
||||||
|
if (!confirm({{ _("devices.confirm_revoke_token") | tojson }})) return;
|
||||||
|
this.revokingToken = token.id;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/api-tokens/${token.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'X-CSRF-Token': csrfToken },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || 'Failed to revoke token');
|
||||||
|
}
|
||||||
|
await this.loadMobileTokens();
|
||||||
|
this._showBanner({{ _("devices.token_revoked_success") | tojson }}, false);
|
||||||
|
} catch (e) {
|
||||||
|
this._showBanner(e.message, true);
|
||||||
|
} finally {
|
||||||
|
this.revokingToken = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async deactivateDevice(device) {
|
||||||
|
if (!confirm({{ _("devices.confirm_deactivate_device") | tojson }})) return;
|
||||||
|
this.deactivatingDevice = device.id;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/mobile/devices/${device.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'X-CSRF-Token': csrfToken },
|
||||||
|
});
|
||||||
|
if (!res.ok && res.status !== 204) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || 'Failed to remove device');
|
||||||
|
}
|
||||||
|
await this.loadDevices();
|
||||||
|
this._showBanner({{ _("devices.device_removed_success") | tojson }}, false);
|
||||||
|
} catch (e) {
|
||||||
|
this._showBanner(e.message, true);
|
||||||
|
} finally {
|
||||||
|
this.deactivatingDevice = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Extract the device name from the full token name (e.g. "Mobile App – iPhone 15 Pro" → "iPhone 15 Pro"). */
|
||||||
|
formatDeviceName(name) {
|
||||||
|
if (!name) return 'Unknown Device';
|
||||||
|
const sep = name.indexOf('–');
|
||||||
|
if (sep > -1) return name.substring(sep + 1).trim();
|
||||||
|
const dash = name.indexOf('-');
|
||||||
|
if (dash > -1) return name.substring(dash + 1).trim();
|
||||||
|
return name;
|
||||||
|
},
|
||||||
|
|
||||||
|
formatDate(d) {
|
||||||
|
if (!d) return '—';
|
||||||
|
const dt = new Date(d);
|
||||||
|
return dt.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) +
|
||||||
|
' ' + dt.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||||
|
},
|
||||||
|
|
||||||
|
_showBanner(msg, isError) {
|
||||||
|
this.banner = { visible: true, error: isError, message: msg };
|
||||||
|
setTimeout(() => { this.banner.visible = false; }, 5000);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -608,6 +608,37 @@
|
|||||||
"dashboard.title": "Dashboard",
|
"dashboard.title": "Dashboard",
|
||||||
"dashboard.total_files": "Total Files",
|
"dashboard.total_files": "Total Files",
|
||||||
"dashboard.welcome": "Welcome to DocuElevate",
|
"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_label": "File ID",
|
||||||
"duplicates.file_id_placeholder": "e.g. 42",
|
"duplicates.file_id_placeholder": "e.g. 42",
|
||||||
"duplicates.find_btn": "Find",
|
"duplicates.find_btn": "Find",
|
||||||
@@ -1152,6 +1183,7 @@
|
|||||||
"nav.dark_mode": "Dark Mode",
|
"nav.dark_mode": "Dark Mode",
|
||||||
"nav.dashboard": "Dashboard",
|
"nav.dashboard": "Dashboard",
|
||||||
"nav.developer_docs": "Developer Docs",
|
"nav.developer_docs": "Developer Docs",
|
||||||
|
"nav.devices": "Devices",
|
||||||
"nav.duplicates": "Duplicates",
|
"nav.duplicates": "Duplicates",
|
||||||
"nav.file_manager": "File Manager",
|
"nav.file_manager": "File Manager",
|
||||||
"nav.files": "Files",
|
"nav.files": "Files",
|
||||||
@@ -1465,25 +1497,6 @@
|
|||||||
"qr_login.step_3": "Point your camera at this QR code",
|
"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.subtitle": "Log in to the mobile app by scanning a QR code from this page.",
|
||||||
"qr_login.time_remaining": "Expires in {seconds} seconds",
|
"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.active_tasks": "Active Tasks",
|
||||||
"queue.auto_refresh_1": "Auto-refreshes every",
|
"queue.auto_refresh_1": "Auto-refreshes every",
|
||||||
"queue.auto_refresh_2": "seconds",
|
"queue.auto_refresh_2": "seconds",
|
||||||
@@ -1545,6 +1558,25 @@
|
|||||||
"search.saved_label": "Saved Searches",
|
"search.saved_label": "Saved Searches",
|
||||||
"search.saved_loading": "Loading...",
|
"search.saved_loading": "Loading...",
|
||||||
"search.title": "Search Documents",
|
"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.audit_log_btn": "Audit Log",
|
||||||
"settings.autocomplete_hint": "Type to search known values, or enter any custom value.",
|
"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",
|
"settings.autocomplete_no_matches": "No matches — you can still type a custom value",
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user