diff --git a/app/api/qr_auth.py b/app/api/qr_auth.py
index b7e1d439..b9dbaa5d 100644
--- a/app/api/qr_auth.py
+++ b/app/api/qr_auth.py
@@ -70,6 +70,7 @@ class CreateChallengeResponse(BaseModel):
challenge_id: int
challenge_token: str
expires_at: datetime
+ ttl_seconds: int = Field(description="Seconds until the challenge expires (use for client-side countdown).")
qr_payload: str = Field(description="The string to encode in the QR code.")
@@ -131,10 +132,16 @@ async def create_challenge(
base_url = str(request.base_url).rstrip("/")
qr_payload = f"docuelevate://qr-login?token={challenge.challenge_token}&server={base_url}"
+ # Compute the TTL in seconds so the client can run a countdown timer
+ # without comparing absolute timestamps (which breaks when client and
+ # server clocks are out of sync).
+ ttl_seconds = max(0, int((challenge.expires_at - challenge.created_at).total_seconds()))
+
return {
"challenge_id": challenge.id,
"challenge_token": challenge.challenge_token,
"expires_at": challenge.expires_at,
+ "ttl_seconds": ttl_seconds,
"qr_payload": qr_payload,
}
diff --git a/frontend/templates/qr_login.html b/frontend/templates/qr_login.html
index 517dd3ca..7b29f594 100644
--- a/frontend/templates/qr_login.html
+++ b/frontend/templates/qr_login.html
@@ -126,6 +126,8 @@ function qrLoginPage() {
errorMsg: '',
_pollTimer: null,
_countdownTimer: null,
+ _ttlSeconds: 0,
+ _receivedAt: null,
_csrfToken() {
return document.cookie
@@ -156,6 +158,8 @@ function qrLoginPage() {
this.challengeToken = data.challenge_token;
this.qrPayload = data.qr_payload;
this.expiresAt = new Date(data.expires_at);
+ this._ttlSeconds = data.ttl_seconds || 120;
+ this._receivedAt = Date.now();
this.status = 'pending';
this.deviceName = '';
@@ -214,8 +218,9 @@ function qrLoginPage() {
},
_updateCountdown() {
- if (!this.expiresAt) { this.countdown = 0; return; }
- const remaining = Math.max(0, Math.floor((this.expiresAt - new Date()) / 1000));
+ if (!this._receivedAt) { this.countdown = 0; return; }
+ const elapsed = (Date.now() - this._receivedAt) / 1000;
+ const remaining = Math.max(0, Math.floor(this._ttlSeconds - elapsed));
this.countdown = remaining;
},
diff --git a/mobile/app.json b/mobile/app.json
index 1aa4aef0..26cc937b 100644
--- a/mobile/app.json
+++ b/mobile/app.json
@@ -19,7 +19,7 @@
"bundleIdentifier": "org.docuelevate.mobile",
"appleTeamId": "975U2ZESBM",
"infoPlist": {
- "NSCameraUsageDescription": "DocuElevate uses the camera to capture documents for upload.",
+ "NSCameraUsageDescription": "DocuElevate uses the camera to scan QR codes for login and to capture documents for upload.",
"NSPhotoLibraryUsageDescription": "DocuElevate accesses your photo library to select documents for upload.",
"NSPhotoLibraryAddUsageDescription": "DocuElevate saves scanned documents to your photo library.",
"UIBackgroundModes": ["fetch", "remote-notification"],
@@ -101,7 +101,7 @@
[
"expo-camera",
{
- "cameraPermission": "DocuElevate uses the camera to capture documents for upload."
+ "cameraPermission": "DocuElevate uses the camera to scan QR codes for login and to capture documents for upload."
}
],
"expo-document-picker",
diff --git a/mobile/app/(auth)/_layout.tsx b/mobile/app/(auth)/_layout.tsx
index 7040faec..e7288998 100644
--- a/mobile/app/(auth)/_layout.tsx
+++ b/mobile/app/(auth)/_layout.tsx
@@ -12,6 +12,7 @@ export default function AuthLayout() {
+
);
}
diff --git a/mobile/app/(auth)/qr-scanner.tsx b/mobile/app/(auth)/qr-scanner.tsx
new file mode 100644
index 00000000..b6c018fb
--- /dev/null
+++ b/mobile/app/(auth)/qr-scanner.tsx
@@ -0,0 +1,4 @@
+/**
+ * QR scanner route – camera-based QR code scanning for mobile login.
+ */
+export { default } from "../../src/screens/QRScannerScreen";
diff --git a/mobile/src/screens/LoginScreen.tsx b/mobile/src/screens/LoginScreen.tsx
index cc2b3133..8a0ace90 100644
--- a/mobile/src/screens/LoginScreen.tsx
+++ b/mobile/src/screens/LoginScreen.tsx
@@ -144,10 +144,7 @@ export default function LoginScreen() {
{
- 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."
- );
+ router.push("/(auth)/qr-scanner");
}}
disabled={loading || qrLoading}
accessibilityRole="button"
diff --git a/mobile/src/screens/QRScannerScreen.tsx b/mobile/src/screens/QRScannerScreen.tsx
new file mode 100644
index 00000000..9831112a
--- /dev/null
+++ b/mobile/src/screens/QRScannerScreen.tsx
@@ -0,0 +1,295 @@
+/**
+ * QRScannerScreen – camera-based QR code scanner for mobile login.
+ *
+ * Opens the device camera and scans for QR codes containing a
+ * `docuelevate://qr-login?token=...&server=...` payload. On successful
+ * scan the token is claimed via the API and the user is signed in.
+ */
+
+import { CameraView, useCameraPermissions } from "expo-camera";
+import { useRouter } from "expo-router";
+import React, { useCallback, useRef, useState } from "react";
+import {
+ ActivityIndicator,
+ Alert,
+ Pressable,
+ StyleSheet,
+ Text,
+ View,
+} from "react-native";
+import { useAuth } from "../context/AuthContext";
+
+export default function QRScannerScreen() {
+ const { signInWithQR } = useAuth();
+ const router = useRouter();
+ const [permission, requestPermission] = useCameraPermissions();
+ const [scanned, setScanned] = useState(false);
+ const [processing, setProcessing] = useState(false);
+ const processingRef = useRef(false);
+
+ const handleBarCodeScanned = useCallback(
+ async (result: { data: string }) => {
+ // Prevent duplicate scans while processing
+ if (processingRef.current) return;
+
+ const { data } = result;
+
+ // Only accept docuelevate:// QR codes
+ if (!data.startsWith("docuelevate://qr-login")) return;
+
+ processingRef.current = true;
+ setScanned(true);
+ setProcessing(true);
+
+ try {
+ const url = new URL(data);
+ const token = url.searchParams.get("token");
+ const server = url.searchParams.get("server");
+
+ if (!token || !server) {
+ Alert.alert("Invalid QR Code", "This QR code does not contain valid login information.");
+ setScanned(false);
+ processingRef.current = false;
+ setProcessing(false);
+ return;
+ }
+
+ await signInWithQR(server, token);
+ // signInWithQR updates AuthContext → AuthGuard redirects to main app
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : "QR login failed";
+ Alert.alert("QR Login Failed", message);
+ setScanned(false);
+ processingRef.current = false;
+ setProcessing(false);
+ }
+ },
+ [signInWithQR]
+ );
+
+ // Permissions not yet determined
+ if (!permission) {
+ return (
+
+
+
+ );
+ }
+
+ // Permission denied
+ if (!permission.granted) {
+ return (
+
+
+ Camera access is required to scan QR codes.
+
+
+ Grant Camera Access
+
+ router.back()}
+ style={styles.backLink}
+ accessibilityRole="button"
+ accessibilityLabel="Go back"
+ >
+ ← Back
+
+
+ );
+ }
+
+ return (
+
+
+
+ {/* Overlay with scan area indicator */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {processing ? (
+
+
+ Signing in…
+
+ ) : (
+
+ Point your camera at the QR code{"\n"}shown on the DocuElevate web app
+
+ )}
+
+ router.back()}
+ style={styles.cancelButton}
+ accessibilityRole="button"
+ accessibilityLabel="Cancel QR scan"
+ >
+ Cancel
+
+
+
+
+ );
+}
+
+const SCAN_AREA_SIZE = 250;
+const CORNER_SIZE = 24;
+const CORNER_WIDTH = 3;
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: "#000",
+ },
+ camera: {
+ flex: 1,
+ },
+ centered: {
+ flex: 1,
+ justifyContent: "center",
+ alignItems: "center",
+ backgroundColor: "#f3f4f6",
+ padding: 24,
+ },
+ permissionText: {
+ fontSize: 16,
+ color: "#374151",
+ textAlign: "center",
+ marginBottom: 20,
+ },
+ permissionButton: {
+ backgroundColor: "#1e40af",
+ borderRadius: 8,
+ paddingVertical: 14,
+ paddingHorizontal: 24,
+ minHeight: 48,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ permissionButtonText: {
+ color: "#fff",
+ fontSize: 16,
+ fontWeight: "600",
+ },
+ backLink: {
+ marginTop: 20,
+ minHeight: 44,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ backLinkText: {
+ fontSize: 14,
+ color: "#6b7280",
+ },
+ overlay: {
+ ...StyleSheet.absoluteFillObject,
+ },
+ overlayTop: {
+ flex: 1,
+ backgroundColor: "rgba(0,0,0,0.5)",
+ },
+ overlayMiddle: {
+ flexDirection: "row",
+ height: SCAN_AREA_SIZE,
+ },
+ overlaySide: {
+ flex: 1,
+ backgroundColor: "rgba(0,0,0,0.5)",
+ },
+ scanArea: {
+ width: SCAN_AREA_SIZE,
+ height: SCAN_AREA_SIZE,
+ },
+ corner: {
+ position: "absolute",
+ width: CORNER_SIZE,
+ height: CORNER_SIZE,
+ },
+ cornerTopLeft: {
+ top: 0,
+ left: 0,
+ borderTopWidth: CORNER_WIDTH,
+ borderLeftWidth: CORNER_WIDTH,
+ borderColor: "#fff",
+ },
+ cornerTopRight: {
+ top: 0,
+ right: 0,
+ borderTopWidth: CORNER_WIDTH,
+ borderRightWidth: CORNER_WIDTH,
+ borderColor: "#fff",
+ },
+ cornerBottomLeft: {
+ bottom: 0,
+ left: 0,
+ borderBottomWidth: CORNER_WIDTH,
+ borderLeftWidth: CORNER_WIDTH,
+ borderColor: "#fff",
+ },
+ cornerBottomRight: {
+ bottom: 0,
+ right: 0,
+ borderBottomWidth: CORNER_WIDTH,
+ borderRightWidth: CORNER_WIDTH,
+ borderColor: "#fff",
+ },
+ overlayBottom: {
+ flex: 1,
+ backgroundColor: "rgba(0,0,0,0.5)",
+ alignItems: "center",
+ paddingTop: 32,
+ },
+ statusContainer: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 10,
+ },
+ statusText: {
+ color: "#fff",
+ fontSize: 16,
+ fontWeight: "600",
+ },
+ instructionText: {
+ color: "#fff",
+ fontSize: 15,
+ textAlign: "center",
+ lineHeight: 22,
+ },
+ cancelButton: {
+ marginTop: 24,
+ paddingVertical: 12,
+ paddingHorizontal: 32,
+ borderRadius: 8,
+ borderWidth: 1,
+ borderColor: "rgba(255,255,255,0.5)",
+ minHeight: 44,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ cancelButtonText: {
+ color: "#fff",
+ fontSize: 15,
+ fontWeight: "500",
+ },
+});
diff --git a/tests/test_session_management.py b/tests/test_session_management.py
index 039aca46..e11c93ca 100644
--- a/tests/test_session_management.py
+++ b/tests/test_session_management.py
@@ -401,6 +401,35 @@ class TestQRLogin:
expires = expires.replace(tzinfo=timezone.utc)
assert expires > datetime.now(timezone.utc)
+ @patch("app.utils.session_manager.settings")
+ def test_create_qr_challenge_ttl_seconds(self, mock_settings, db_session: Session, sample_user_id: str):
+ """Test that ttl_seconds can be derived from created_at and expires_at.
+
+ The API endpoint computes ttl_seconds = (expires_at - created_at) to
+ allow the client to run a countdown timer without comparing absolute
+ timestamps (avoiding clock-skew issues).
+ """
+ from app.utils.session_manager import create_qr_challenge
+
+ mock_settings.qr_login_challenge_ttl_seconds = 120
+
+ challenge = create_qr_challenge(db_session, sample_user_id)
+
+ ttl_seconds = max(0, int((challenge.expires_at - challenge.created_at).total_seconds()))
+ assert ttl_seconds == 120
+
+ @patch("app.utils.session_manager.settings")
+ def test_create_qr_challenge_custom_ttl(self, mock_settings, db_session: Session, sample_user_id: str):
+ """Test that a custom TTL is correctly reflected in the challenge timestamps."""
+ from app.utils.session_manager import create_qr_challenge
+
+ mock_settings.qr_login_challenge_ttl_seconds = 300
+
+ challenge = create_qr_challenge(db_session, sample_user_id)
+
+ ttl_seconds = max(0, int((challenge.expires_at - challenge.created_at).total_seconds()))
+ assert ttl_seconds == 300
+
@patch("app.utils.session_manager.settings")
def test_validate_qr_challenge_valid(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test validating a valid QR challenge."""