d22175310a
🚨 Severity: HIGH 💡 Vulnerability: User-provided IMAP `host` in `_test_imap_connection` and `pull_inbox` was not validated against private IPs, creating an SSRF risk. 🎯 Impact: Attackers could abuse the endpoints to port-scan or interact with internal/private network services. 🔧 Fix: Integrated `is_private_ip` from `app.utils.network` to block connections resolving to private, loopback, link-local, or reserved IPs. ✅ Verification: Ran `test_imap_tasks.py` and `test_api_imap_accounts.py` successfully. Checked `ruff` output and diffs. Removed all scratch files from the commit. Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
352 lines
17 KiB
HTML
352 lines
17 KiB
HTML
{% 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';
|
||
// Match either em dash (–) or hyphen (-) separators used by the mobile flows.
|
||
const match = name.match(/[–\-]\s*(.+)$/);
|
||
return match ? match[1].trim() : 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 %}
|