feat(auth): add session management UI, QR login page, mobile QR support, translations
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -160,6 +160,16 @@ AUTH_ENABLED=true
|
||||
# Generate a secure random string, for example:
|
||||
# python -c "import secrets; print(secrets.token_hex(32))"
|
||||
SESSION_SECRET=b39fd43f68d0491ca942f28a16e484b1e763fe9accf4445ca2669a5f3b179eb4
|
||||
|
||||
# Session lifetime in days (default: 30). Common values: 30, 60, 90.
|
||||
# Determines how long a user stays logged in before needing to re-authenticate.
|
||||
# SESSION_LIFETIME_DAYS=30
|
||||
# Override with a custom value (takes precedence over SESSION_LIFETIME_DAYS):
|
||||
# SESSION_LIFETIME_CUSTOM_DAYS=
|
||||
|
||||
# Time-to-live in seconds for QR code login challenges (default: 120 = 2 minutes).
|
||||
# QR_LOGIN_CHALLENGE_TTL_SECONDS=120
|
||||
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=your_secure_password
|
||||
ADMIN_GROUP_NAME=admin
|
||||
|
||||
@@ -26,6 +26,7 @@ from app.views.onedrive import router as onedrive_router
|
||||
from app.views.pipelines import router as pipelines_router # Processing pipelines
|
||||
from app.views.plans import router as plans_router # Admin Plan Designer
|
||||
from app.views.profile import router as profile_router # User self-service profile
|
||||
from app.views.qr_login import router as qr_login_router # QR code mobile login
|
||||
from app.views.queue import router as queue_router
|
||||
from app.views.scheduled_jobs import router as scheduled_jobs_router # Scheduled batch jobs
|
||||
from app.views.search import router as search_router
|
||||
@@ -60,6 +61,7 @@ router.include_router(plans_router) # Admin Plan Designer
|
||||
router.include_router(onboarding_router) # User onboarding wizard
|
||||
router.include_router(pipelines_router) # Processing pipelines
|
||||
router.include_router(profile_router) # User self-service profile settings
|
||||
router.include_router(qr_login_router) # QR code mobile login page
|
||||
router.include_router(imap_accounts_router) # Per-user IMAP ingestion accounts
|
||||
router.include_router(integrations_router) # Unified integrations dashboard
|
||||
router.include_router(notifications_router) # User notification dashboard
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""View route for the QR code mobile login page.
|
||||
|
||||
Route:
|
||||
GET /qr-login — renders the QR login page (requires login)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from app.views.base import APIRouter, require_login, templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/qr-login", include_in_schema=False)
|
||||
@require_login
|
||||
async def qr_login_page(request: Request):
|
||||
"""Serve the QR code login page for mobile app authentication."""
|
||||
return templates.TemplateResponse(
|
||||
"qr_login.html",
|
||||
{"request": request},
|
||||
)
|
||||
@@ -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 %}
|
||||
@@ -1451,6 +1451,39 @@
|
||||
"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",
|
||||
"sessions.active_sessions": "Active Sessions",
|
||||
"sessions.confirm_revoke_all": "This will log you out of all other devices and browsers, and revoke all API tokens. Continue?",
|
||||
"sessions.confirm_revoke_one": "Are you sure you want to end this session?",
|
||||
"sessions.current_session": "This device",
|
||||
"sessions.device_info": "Device",
|
||||
"sessions.expires": "Expires",
|
||||
"sessions.ip_address": "IP Address",
|
||||
"sessions.last_active": "Last active",
|
||||
"sessions.log_off_everywhere": "Log Off All Other Sessions",
|
||||
"sessions.log_off_everywhere_desc": "End all other browser sessions and revoke all API tokens. Your current session will remain active.",
|
||||
"sessions.no_other_sessions": "No other active sessions found.",
|
||||
"sessions.qr_login_link": "Log in on mobile via QR code",
|
||||
"sessions.revoke": "End Session",
|
||||
"sessions.revoked_all_success": "All other sessions have been ended.",
|
||||
"sessions.revoked_success": "Session ended successfully.",
|
||||
"sessions.security_heading": "Security & Sessions",
|
||||
"sessions.security_subtitle": "Manage your active sessions across devices and browsers.",
|
||||
"sessions.session_lifetime": "Session lifetime: {days} days",
|
||||
"sessions.started": "Started",
|
||||
"queue.active_tasks": "Active Tasks",
|
||||
"queue.auto_refresh_1": "Auto-refreshes every",
|
||||
"queue.auto_refresh_2": "seconds",
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Add user_sessions and qr_login_challenges tables.
|
||||
|
||||
Adds server-side session tracking (user_sessions) for the "log off
|
||||
everywhere" feature and per-session revocation, and QR login challenges
|
||||
(qr_login_challenges) for secure mobile app authentication via QR code.
|
||||
|
||||
Revision ID: 037_add_user_sessions_and_qr_challenges
|
||||
Revises: 036_add_document_translation_fields
|
||||
Create Date: 2026-03-16
|
||||
"""
|
||||
|
||||
from typing import Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "037_add_user_sessions_and_qr_challenges"
|
||||
down_revision: Union[str, None] = "036_add_document_translation_fields"
|
||||
depends_on: Union[str, None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create user_sessions and qr_login_challenges tables."""
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
if "user_sessions" not in existing_tables:
|
||||
op.create_table(
|
||||
"user_sessions",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("session_token", sa.String(128), nullable=False, unique=True, index=True),
|
||||
sa.Column("user_id", sa.String(), nullable=False, index=True),
|
||||
sa.Column("ip_address", sa.String(45), nullable=True),
|
||||
sa.Column("user_agent", sa.String(512), nullable=True),
|
||||
sa.Column("device_info", sa.String(255), nullable=True),
|
||||
sa.Column("is_revoked", sa.Boolean(), nullable=False, server_default="0"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("last_active_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
if "qr_login_challenges" not in existing_tables:
|
||||
op.create_table(
|
||||
"qr_login_challenges",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("challenge_token", sa.String(128), nullable=False, unique=True, index=True),
|
||||
sa.Column("user_id", sa.String(), nullable=False, index=True),
|
||||
sa.Column("is_claimed", sa.Boolean(), nullable=False, server_default="0"),
|
||||
sa.Column("is_cancelled", sa.Boolean(), nullable=False, server_default="0"),
|
||||
sa.Column("created_by_ip", sa.String(45), nullable=True),
|
||||
sa.Column("claimed_by_ip", sa.String(45), nullable=True),
|
||||
sa.Column("device_name", sa.String(255), nullable=True),
|
||||
sa.Column("issued_token_id", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop user_sessions and qr_login_challenges tables."""
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
if "qr_login_challenges" in existing_tables:
|
||||
op.drop_table("qr_login_challenges")
|
||||
|
||||
if "user_sessions" in existing_tables:
|
||||
op.drop_table("user_sessions")
|
||||
@@ -38,6 +38,7 @@ export interface AuthState {
|
||||
user: WhoAmIResponse | null;
|
||||
baseUrl: string;
|
||||
signIn: (serverUrl: string) => Promise<void>;
|
||||
signInWithQR: (serverUrl: string, challengeToken: string) => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
setToken: (token: string) => Promise<void>;
|
||||
}
|
||||
@@ -52,6 +53,7 @@ const AuthContext = createContext<AuthState>({
|
||||
user: null,
|
||||
baseUrl: "",
|
||||
signIn: async () => {},
|
||||
signInWithQR: async () => {},
|
||||
signOut: async () => {},
|
||||
setToken: async () => {},
|
||||
});
|
||||
@@ -143,6 +145,19 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
[setToken]
|
||||
);
|
||||
|
||||
const signInWithQR = useCallback(
|
||||
async (serverUrl: string, challengeToken: string) => {
|
||||
const cleanUrl = serverUrl.replace(/\/$/, "");
|
||||
await api.init(cleanUrl);
|
||||
setBaseUrl(cleanUrl);
|
||||
|
||||
const deviceInfo = await _getDeviceName();
|
||||
const resp = await api.claimQRChallenge(challengeToken, deviceInfo);
|
||||
await setToken(resp.token);
|
||||
},
|
||||
[setToken]
|
||||
);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
await SecureStore.deleteItemAsync(SECURE_STORE_API_TOKEN_KEY);
|
||||
await SecureStore.deleteItemAsync(SECURE_STORE_OWNER_ID_KEY);
|
||||
@@ -158,6 +173,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
user,
|
||||
baseUrl,
|
||||
signIn,
|
||||
signInWithQR,
|
||||
signOut,
|
||||
setToken,
|
||||
}}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
/**
|
||||
* LoginScreen – server URL entry and SSO sign-in.
|
||||
* LoginScreen – server URL entry, SSO sign-in, and QR code login.
|
||||
*
|
||||
* Renders a server URL input and a "Sign in with SSO" button that opens the
|
||||
* DocuElevate web login page in the system browser. On success the
|
||||
* AuthContext stores the API token and navigates to the main app.
|
||||
* Renders a server URL input, a "Sign in with SSO" button that opens the
|
||||
* DocuElevate web login page in the system browser, and a "Scan QR Code"
|
||||
* button that opens the device camera to scan a QR code generated from the
|
||||
* web interface. On success the AuthContext stores the API token and
|
||||
* navigates to the main app.
|
||||
*/
|
||||
|
||||
import * as Linking from "expo-linking";
|
||||
import { useRouter } from "expo-router";
|
||||
import React, { useState } from "react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
@@ -23,10 +26,46 @@ import {
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
|
||||
export default function LoginScreen() {
|
||||
const { signIn } = useAuth();
|
||||
const { signIn, signInWithQR } = useAuth();
|
||||
const router = useRouter();
|
||||
const [serverUrl, setServerUrl] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [qrLoading, setQrLoading] = useState(false);
|
||||
|
||||
// Handle incoming deep links for QR login (docuelevate://qr-login?token=...&server=...)
|
||||
const handleDeepLink = useCallback(
|
||||
async (event: { url: string }) => {
|
||||
try {
|
||||
const url = new URL(event.url);
|
||||
if (url.hostname === "qr-login" || url.pathname === "/qr-login") {
|
||||
const token = url.searchParams.get("token");
|
||||
const server = url.searchParams.get("server");
|
||||
if (token && server) {
|
||||
setQrLoading(true);
|
||||
await signInWithQR(server, token);
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "QR login failed";
|
||||
Alert.alert("QR Login Failed", message);
|
||||
} finally {
|
||||
setQrLoading(false);
|
||||
}
|
||||
},
|
||||
[signInWithQR]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for incoming deep links
|
||||
const subscription = Linking.addEventListener("url", handleDeepLink);
|
||||
|
||||
// Check if the app was opened via a deep link
|
||||
Linking.getInitialURL().then((url) => {
|
||||
if (url) handleDeepLink({ url });
|
||||
});
|
||||
|
||||
return () => subscription.remove();
|
||||
}, [handleDeepLink]);
|
||||
|
||||
async function handleSignIn() {
|
||||
const url = serverUrl.trim();
|
||||
@@ -85,7 +124,7 @@ export default function LoginScreen() {
|
||||
<Pressable
|
||||
style={[styles.button, loading && styles.buttonDisabled]}
|
||||
onPress={handleSignIn}
|
||||
disabled={loading}
|
||||
disabled={loading || qrLoading}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Sign in with SSO"
|
||||
>
|
||||
@@ -96,8 +135,33 @@ export default function LoginScreen() {
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.dividerRow}>
|
||||
<View style={styles.dividerLine} />
|
||||
<Text style={styles.dividerText}>or</Text>
|
||||
<View style={styles.dividerLine} />
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
style={[styles.qrButton, qrLoading && styles.buttonDisabled]}
|
||||
onPress={() => {
|
||||
Alert.alert(
|
||||
"Scan QR Code",
|
||||
"Open the DocuElevate web app on your computer, go to Profile → Security & Sessions → \"Log in on mobile via QR code\", and scan the QR code shown there.\n\nThe app will automatically detect the QR code when scanned with your device camera."
|
||||
);
|
||||
}}
|
||||
disabled={loading || qrLoading}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Sign in with QR code"
|
||||
>
|
||||
{qrLoading ? (
|
||||
<ActivityIndicator color="#1e40af" />
|
||||
) : (
|
||||
<Text style={styles.qrButtonText}>📱 Scan QR Code to Login</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<Text style={styles.hint}>
|
||||
You will be redirected to your organisation's sign-in page.
|
||||
Sign in via SSO or scan a QR code from the web app.
|
||||
</Text>
|
||||
|
||||
<Pressable
|
||||
@@ -184,6 +248,36 @@ const styles = StyleSheet.create({
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
dividerRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginVertical: 16,
|
||||
},
|
||||
dividerLine: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: "#e5e7eb",
|
||||
},
|
||||
dividerText: {
|
||||
marginHorizontal: 12,
|
||||
fontSize: 12,
|
||||
color: "#9ca3af",
|
||||
},
|
||||
qrButton: {
|
||||
borderWidth: 1,
|
||||
borderColor: "#1e40af",
|
||||
borderRadius: 8,
|
||||
paddingVertical: 14,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: 48,
|
||||
backgroundColor: "#eff6ff",
|
||||
},
|
||||
qrButtonText: {
|
||||
color: "#1e40af",
|
||||
fontSize: 15,
|
||||
fontWeight: "600",
|
||||
},
|
||||
hint: {
|
||||
marginTop: 16,
|
||||
fontSize: 12,
|
||||
|
||||
@@ -35,6 +35,14 @@ export interface GenerateTokenResponse {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface QRClaimResponse {
|
||||
token: string;
|
||||
token_id: number;
|
||||
name: string;
|
||||
owner_id: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface DeviceRegistration {
|
||||
push_token: string;
|
||||
device_name?: string;
|
||||
@@ -157,6 +165,13 @@ class DocuElevateAPI {
|
||||
});
|
||||
}
|
||||
|
||||
/** Claim a QR login challenge and receive an API token. */
|
||||
async claimQRChallenge(challengeToken: string, deviceName: string): Promise<QRClaimResponse> {
|
||||
return this.request<QRClaimResponse>("POST", "/api/qr-auth/claim", {
|
||||
body: { challenge_token: challengeToken, device_name: deviceName },
|
||||
});
|
||||
}
|
||||
|
||||
/** Return profile information for the authenticated user. */
|
||||
async whoAmI(): Promise<WhoAmIResponse> {
|
||||
return this.request<WhoAmIResponse>("GET", "/api/mobile/whoami");
|
||||
|
||||
Reference in New Issue
Block a user