fix: merge main into feature branch - resolve all 18 conflicts cleanly
Merges origin/main (v0.155.0) into the classification feature branch, resolving all 18 conflicted files by accepting main's version and re-applying only classification-specific additions: - Renumber migration from 037 to 038 (chains from 037_user_sessions) - Re-add ClassificationRuleModel to models.py, env.py, conftest.py - Re-add classification_rules_router to api/__init__.py - All session management, QR auth, and devices code preserved from main Migration chain validated. 62 classification tests pass. Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -570,6 +570,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 }},
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
{% 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 %}
|
||||
@@ -343,9 +343,203 @@
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<!-- ── Security & Sessions card ──────────────────────────────────────── -->
|
||||
<section
|
||||
class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6"
|
||||
aria-labelledby="security-heading"
|
||||
x-data="sessionManager()"
|
||||
x-init="loadSessions()"
|
||||
>
|
||||
<h2 id="security-heading" class="text-base font-semibold text-gray-900 dark:text-white mb-1">
|
||||
<i class="fas fa-shield-alt text-gray-400 mr-2" aria-hidden="true"></i>{{ _("sessions.security_heading") }}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
{{ _("sessions.security_subtitle") }}
|
||||
</p>
|
||||
|
||||
<!-- Session lifetime info -->
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500 mb-4" x-show="lifetimeDays > 0">
|
||||
<i class="fas fa-clock mr-1" aria-hidden="true"></i>
|
||||
<span x-text="'{{ _("sessions.session_lifetime") }}'.replace('{days}', lifetimeDays)"></span>
|
||||
</div>
|
||||
|
||||
<!-- Active sessions list -->
|
||||
<div class="space-y-3 mb-5">
|
||||
<template x-for="session in sessions" :key="session.id">
|
||||
<div
|
||||
class="flex items-center justify-between border border-gray-200 dark:border-gray-700 rounded-lg p-3"
|
||||
:class="session.is_current ? 'bg-blue-50 dark:bg-blue-900/20 border-blue-300 dark:border-blue-700' : ''"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<i
|
||||
:class="session.device_info && session.device_info.includes('iPhone') ? 'fas fa-mobile-alt' :
|
||||
session.device_info && session.device_info.includes('iPad') ? 'fas fa-tablet-alt' :
|
||||
session.device_info && session.device_info.includes('Android') ? 'fas fa-mobile-alt' :
|
||||
session.device_info && session.device_info.includes('App') ? 'fas fa-mobile-alt' :
|
||||
'fas fa-desktop'"
|
||||
class="text-gray-400 text-lg 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="session.device_info || 'Unknown Device'"></span>
|
||||
<span
|
||||
x-show="session.is_current"
|
||||
class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
|
||||
>{{ _("sessions.current_session") }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 space-x-3">
|
||||
<span x-show="session.ip_address">
|
||||
<i class="fas fa-globe mr-1" aria-hidden="true"></i><span x-text="session.ip_address"></span>
|
||||
</span>
|
||||
<span>
|
||||
<i class="fas fa-clock mr-1" aria-hidden="true"></i>{{ _("sessions.last_active") }}
|
||||
<span x-text="timeAgo(session.last_active_at)"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
x-show="!session.is_current"
|
||||
@click="revokeSession(session.id)"
|
||||
class="flex-shrink-0 text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 text-sm font-medium px-3 py-1 rounded hover:bg-red-50 dark:hover:bg-red-900/20 transition"
|
||||
style="min-height:44px; min-width:44px;"
|
||||
:aria-label="'{{ _("sessions.revoke") }}'"
|
||||
>
|
||||
<i class="fas fa-sign-out-alt mr-1" aria-hidden="true"></i>{{ _("sessions.revoke") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<p
|
||||
x-show="sessions.length <= 1"
|
||||
class="text-sm text-gray-500 dark:text-gray-400 italic"
|
||||
>{{ _("sessions.no_other_sessions") }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Log off everywhere + QR login row -->
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
@click="revokeAllSessions()"
|
||||
class="inline-flex items-center justify-center px-4 py-2 border border-red-300 dark:border-red-700 rounded-lg text-sm font-medium text-red-700 dark:text-red-300 bg-white dark:bg-gray-800 hover:bg-red-50 dark:hover:bg-red-900/20 transition"
|
||||
style="min-height:44px;"
|
||||
:disabled="revoking"
|
||||
>
|
||||
<i class="fas fa-power-off mr-2" aria-hidden="true"></i>
|
||||
<span x-text="revoking ? '{{ _("profile.saving") }}' : '{{ _("sessions.log_off_everywhere") }}'"></span>
|
||||
</button>
|
||||
<a
|
||||
href="/qr-login"
|
||||
class="inline-flex items-center justify-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition"
|
||||
style="min-height:44px;"
|
||||
>
|
||||
<i class="fas fa-qrcode mr-2" aria-hidden="true"></i>
|
||||
{{ _("sessions.qr_login_link") }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Status banner for session actions -->
|
||||
<div
|
||||
x-show="sessionBanner.visible"
|
||||
x-transition
|
||||
class="mt-4 rounded-lg p-3 text-sm"
|
||||
:class="sessionBanner.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="sessionBanner.message"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div><!-- /container -->
|
||||
|
||||
<script>
|
||||
/* ── Session Manager Alpine component ──────────────────────────────────── */
|
||||
function sessionManager() {
|
||||
return {
|
||||
sessions: [],
|
||||
lifetimeDays: 0,
|
||||
revoking: false,
|
||||
sessionBanner: { visible: false, error: false, message: '' },
|
||||
|
||||
_csrfToken() {
|
||||
return document.cookie
|
||||
.split('; ')
|
||||
.find(row => row.startsWith('csrf_token='))
|
||||
?.split('=')[1];
|
||||
},
|
||||
|
||||
async loadSessions() {
|
||||
try {
|
||||
const res = await fetch('/api/sessions/');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
this.sessions = data.sessions || [];
|
||||
this.lifetimeDays = data.session_lifetime_days || 30;
|
||||
}
|
||||
} catch (_e) { /* silently ignore */ }
|
||||
},
|
||||
|
||||
async revokeSession(sessionId) {
|
||||
if (!confirm({{ _("sessions.confirm_revoke_one") | tojson }})) return;
|
||||
const csrf = this._csrfToken();
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
headers: csrf ? { 'X-CSRF-Token': csrf } : {},
|
||||
});
|
||||
if (res.ok || res.status === 204) {
|
||||
this.sessions = this.sessions.filter(s => s.id !== sessionId);
|
||||
this._showSessionBanner({{ _("sessions.revoked_success") | tojson }}, false);
|
||||
}
|
||||
} catch (_e) {
|
||||
this._showSessionBanner('Network error — please try again.', true);
|
||||
}
|
||||
},
|
||||
|
||||
async revokeAllSessions() {
|
||||
if (!confirm({{ _("sessions.confirm_revoke_all") | tojson }})) return;
|
||||
this.revoking = true;
|
||||
const csrf = this._csrfToken();
|
||||
try {
|
||||
const res = await fetch('/api/sessions/revoke-all', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(csrf ? { 'X-CSRF-Token': csrf } : {}),
|
||||
},
|
||||
});
|
||||
if (res.ok) {
|
||||
await this.loadSessions();
|
||||
this._showSessionBanner({{ _("sessions.revoked_all_success") | tojson }}, false);
|
||||
}
|
||||
} catch (_e) {
|
||||
this._showSessionBanner('Network error — please try again.', true);
|
||||
} finally {
|
||||
this.revoking = false;
|
||||
}
|
||||
},
|
||||
|
||||
timeAgo(dateStr) {
|
||||
if (!dateStr) return 'unknown';
|
||||
const now = new Date();
|
||||
const then = new Date(dateStr);
|
||||
const diff = Math.floor((now - then) / 1000);
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
||||
return Math.floor(diff / 86400) + 'd ago';
|
||||
},
|
||||
|
||||
_showSessionBanner(msg, err) {
|
||||
this.sessionBanner = { visible: true, error: err, message: msg };
|
||||
if (!err) setTimeout(() => { this.sessionBanner.visible = false; }, 4000);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Profile Settings Alpine component ─────────────────────────────────── */
|
||||
function profileSettings() {
|
||||
return {
|
||||
// ── State ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _("qr_login.page_title") }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div
|
||||
x-data="qrLoginPage()"
|
||||
x-init="generateChallenge()"
|
||||
class="container mx-auto px-4 py-8 max-w-xl"
|
||||
>
|
||||
|
||||
<!-- ── Header ─────────────────────────────────────────────────────────── -->
|
||||
<header class="mb-8 text-center">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center justify-center gap-2">
|
||||
<i class="fas fa-qrcode text-blue-500" aria-hidden="true"></i>
|
||||
{{ _("qr_login.heading") }}
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ _("qr_login.subtitle") }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- ── QR Code Card ───────────────────────────────────────────────────── -->
|
||||
<section
|
||||
class="bg-white dark:bg-gray-800 shadow rounded-lg p-8 mb-6 text-center"
|
||||
aria-labelledby="qr-heading"
|
||||
>
|
||||
<!-- Pending state: show QR code -->
|
||||
<template x-if="status === 'pending'">
|
||||
<div>
|
||||
<div
|
||||
class="mx-auto mb-4 bg-white p-4 inline-block rounded-lg shadow-inner"
|
||||
id="qr-container"
|
||||
aria-label="{{ _('qr_login.description') }}"
|
||||
>
|
||||
<canvas id="qr-canvas" width="256" height="256"></canvas>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||||
{{ _("qr_login.description") }}
|
||||
</p>
|
||||
<div class="flex items-center justify-center gap-2 text-xs text-gray-400 dark:text-gray-500">
|
||||
<i class="fas fa-hourglass-half animate-pulse" aria-hidden="true"></i>
|
||||
<span x-text="'{{ _("qr_login.time_remaining") }}'.replace('{seconds}', countdown)"></span>
|
||||
</div>
|
||||
<p class="mt-3 text-sm text-blue-600 dark:text-blue-400">
|
||||
<i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i>
|
||||
{{ _("qr_login.pending_message") }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Claimed state: success -->
|
||||
<template x-if="status === 'claimed'">
|
||||
<div class="py-8">
|
||||
<i class="fas fa-check-circle text-green-500 text-5xl mb-4" aria-hidden="true"></i>
|
||||
<p class="text-lg font-semibold text-green-700 dark:text-green-400 mb-2">
|
||||
{{ _("qr_login.claimed_message") }}
|
||||
</p>
|
||||
<p x-show="deviceName" class="text-sm text-gray-500 dark:text-gray-400"
|
||||
x-text="'{{ _("qr_login.claimed_device") }}'.replace('{device_name}', deviceName)">
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Expired state -->
|
||||
<template x-if="status === 'expired'">
|
||||
<div class="py-8">
|
||||
<i class="fas fa-clock text-yellow-500 text-5xl mb-4" aria-hidden="true"></i>
|
||||
<p class="text-base text-gray-700 dark:text-gray-300 mb-4">
|
||||
{{ _("qr_login.expired_message") }}
|
||||
</p>
|
||||
<button
|
||||
@click="generateChallenge()"
|
||||
class="inline-flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition"
|
||||
style="min-height:44px;"
|
||||
>
|
||||
<i class="fas fa-redo mr-2" aria-hidden="true"></i>
|
||||
{{ _("qr_login.generate_new") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error state -->
|
||||
<template x-if="status === 'error'">
|
||||
<div class="py-8">
|
||||
<i class="fas fa-exclamation-triangle text-red-500 text-5xl mb-4" aria-hidden="true"></i>
|
||||
<p class="text-base text-gray-700 dark:text-gray-300 mb-4" x-text="errorMsg"></p>
|
||||
<button
|
||||
@click="generateChallenge()"
|
||||
class="inline-flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition"
|
||||
style="min-height:44px;"
|
||||
>
|
||||
<i class="fas fa-redo mr-2" aria-hidden="true"></i>
|
||||
{{ _("qr_login.generate_new") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- ── How it works ───────────────────────────────────────────────────── -->
|
||||
<section class="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||
<h2 class="text-base font-semibold text-gray-900 dark:text-white mb-3">
|
||||
<i class="fas fa-info-circle text-gray-400 mr-2" aria-hidden="true"></i>
|
||||
{{ _("qr_login.how_it_works") }}
|
||||
</h2>
|
||||
<ol class="list-decimal list-inside space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<li>{{ _("qr_login.step_1") }}</li>
|
||||
<li>{{ _("qr_login.step_2") }}</li>
|
||||
<li>{{ _("qr_login.step_3") }}</li>
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- QR Code library (lightweight, no external deps) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.4/build/qrcode.min.js"></script>
|
||||
|
||||
<script>
|
||||
function qrLoginPage() {
|
||||
return {
|
||||
status: 'loading', // loading | pending | claimed | expired | error
|
||||
challengeId: null,
|
||||
challengeToken: '',
|
||||
qrPayload: '',
|
||||
expiresAt: null,
|
||||
countdown: 0,
|
||||
deviceName: '',
|
||||
errorMsg: '',
|
||||
_pollTimer: null,
|
||||
_countdownTimer: null,
|
||||
|
||||
_csrfToken() {
|
||||
return document.cookie
|
||||
.split('; ')
|
||||
.find(row => row.startsWith('csrf_token='))
|
||||
?.split('=')[1];
|
||||
},
|
||||
|
||||
async generateChallenge() {
|
||||
this.status = 'loading';
|
||||
this._stopTimers();
|
||||
const csrf = this._csrfToken();
|
||||
try {
|
||||
const res = await fetch('/api/qr-auth/challenge', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(csrf ? { 'X-CSRF-Token': csrf } : {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
this.status = 'error';
|
||||
this.errorMsg = 'Failed to generate QR code. Please try again.';
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
this.challengeId = data.challenge_id;
|
||||
this.challengeToken = data.challenge_token;
|
||||
this.qrPayload = data.qr_payload;
|
||||
this.expiresAt = new Date(data.expires_at);
|
||||
this.status = 'pending';
|
||||
this.deviceName = '';
|
||||
|
||||
// Render QR code
|
||||
this.$nextTick(() => {
|
||||
const canvas = document.getElementById('qr-canvas');
|
||||
if (canvas && typeof QRCode !== 'undefined') {
|
||||
QRCode.toCanvas(canvas, this.qrPayload, {
|
||||
width: 256,
|
||||
margin: 2,
|
||||
color: { dark: '#000000', light: '#ffffff' },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Start polling and countdown
|
||||
this._startPolling();
|
||||
this._startCountdown();
|
||||
} catch (_e) {
|
||||
this.status = 'error';
|
||||
this.errorMsg = 'Network error — please check your connection and try again.';
|
||||
}
|
||||
},
|
||||
|
||||
_startPolling() {
|
||||
this._pollTimer = setInterval(async () => {
|
||||
if (this.status !== 'pending') { this._stopTimers(); return; }
|
||||
try {
|
||||
const res = await fetch(`/api/qr-auth/challenge/${this.challengeId}/status`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (data.status === 'claimed') {
|
||||
this.status = 'claimed';
|
||||
this.deviceName = data.device_name || '';
|
||||
this._stopTimers();
|
||||
} else if (data.status === 'expired') {
|
||||
this.status = 'expired';
|
||||
this._stopTimers();
|
||||
} else if (data.status === 'cancelled') {
|
||||
this.status = 'expired';
|
||||
this._stopTimers();
|
||||
}
|
||||
} catch (_e) { /* ignore transient errors */ }
|
||||
}, 2000);
|
||||
},
|
||||
|
||||
_startCountdown() {
|
||||
this._updateCountdown();
|
||||
this._countdownTimer = setInterval(() => {
|
||||
this._updateCountdown();
|
||||
if (this.countdown <= 0 && this.status === 'pending') {
|
||||
this.status = 'expired';
|
||||
this._stopTimers();
|
||||
}
|
||||
}, 1000);
|
||||
},
|
||||
|
||||
_updateCountdown() {
|
||||
if (!this.expiresAt) { this.countdown = 0; return; }
|
||||
const remaining = Math.max(0, Math.floor((this.expiresAt - new Date()) / 1000));
|
||||
this.countdown = remaining;
|
||||
},
|
||||
|
||||
_stopTimers() {
|
||||
if (this._pollTimer) { clearInterval(this._pollTimer); this._pollTimer = null; }
|
||||
if (this._countdownTimer) { clearInterval(this._countdownTimer); this._countdownTimer = null; }
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -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",
|
||||
@@ -1452,6 +1484,20 @@
|
||||
"profile.theme_system": "System Default",
|
||||
"profile.update_password": "Update Password",
|
||||
"profile.updating": "Updating…",
|
||||
"qr_login.claimed_device": "Device: {device_name}",
|
||||
"qr_login.claimed_message": "QR code login successful! Your mobile device is now connected.",
|
||||
"qr_login.description": "Scan this QR code with the DocuElevate mobile app to log in instantly.",
|
||||
"qr_login.expired_message": "This QR code has expired. Please generate a new one.",
|
||||
"qr_login.generate_new": "Generate New QR Code",
|
||||
"qr_login.heading": "Mobile App QR Login",
|
||||
"qr_login.how_it_works": "How it works",
|
||||
"qr_login.page_title": "QR Code Login – DocuElevate",
|
||||
"qr_login.pending_message": "Waiting for mobile app to scan…",
|
||||
"qr_login.step_1": "Open the DocuElevate app on your phone",
|
||||
"qr_login.step_2": "Tap \"Scan QR Code\" on the login screen",
|
||||
"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",
|
||||
"queue.active_tasks": "Active Tasks",
|
||||
"queue.auto_refresh_1": "Auto-refreshes every",
|
||||
"queue.auto_refresh_2": "seconds",
|
||||
@@ -1513,6 +1559,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",
|
||||
|
||||
Reference in New Issue
Block a user