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:
copilot-swe-agent[bot]
2026-03-16 22:38:13 +00:00
parent a4588a57cb
commit 51821092f4
10 changed files with 699 additions and 8 deletions
+10
View File
@@ -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
+2
View File
@@ -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
+26
View File
@@ -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},
)
+194
View File
@@ -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 ──────────────────────────────────────────────────────────────
+229
View File
@@ -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 %}
+33
View File
@@ -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")
+16
View File
@@ -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,
}}
+102 -8
View File
@@ -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,
+15
View File
@@ -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");