feat(mobile): add iOS/Android mobile app with SSO login, camera upload, and push notifications
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -510,3 +510,12 @@ EMBEDDING_MAX_TOKENS=8000
|
||||
# Attach PII (IP addresses, user agents) to Sentry events.
|
||||
# Disable (default) to stay GDPR/CCPA compliant.
|
||||
# SENTRY_SEND_DEFAULT_PII=false
|
||||
|
||||
# **Mobile App – Push Notifications**
|
||||
# Push notifications are delivered via Expo's push notification service
|
||||
# (https://expo.dev/notifications) which routes to APNs (iOS) and FCM (Android).
|
||||
# No additional credentials are required on the server side.
|
||||
# The mobile app registers its Expo push token via POST /api/mobile/register-device.
|
||||
#
|
||||
# To use native FCM/APNs directly (without Expo relay), replace the
|
||||
# send_expo_push_notification function in app/utils/push_notification.py.
|
||||
|
||||
@@ -20,6 +20,7 @@ from app.api.google_drive import router as google_drive_router
|
||||
from app.api.imap_accounts import router as imap_accounts_router
|
||||
from app.api.integrations import router as integrations_router
|
||||
from app.api.logs import router as logs_router
|
||||
from app.api.mobile import router as mobile_router
|
||||
from app.api.notifications import router as notifications_router
|
||||
from app.api.onboarding import router as onboarding_router
|
||||
from app.api.onedrive import router as onedrive_router
|
||||
@@ -82,3 +83,4 @@ router.include_router(imap_accounts_router)
|
||||
router.include_router(integrations_router)
|
||||
router.include_router(notifications_router)
|
||||
router.include_router(scheduled_jobs_router)
|
||||
router.include_router(mobile_router)
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
"""Mobile app API endpoints.
|
||||
|
||||
Provides endpoints specifically designed for the DocuElevate native mobile
|
||||
app (iOS / Android via React Native / Expo):
|
||||
|
||||
* ``POST /mobile/generate-token`` – exchange an active session for a
|
||||
long-lived API token that the mobile app stores securely. The token is
|
||||
auto-named "Mobile App – <device_name>" and is identical to regular API
|
||||
tokens (Bearer auth works everywhere).
|
||||
|
||||
* ``POST /mobile/register-device`` – register a push-notification device
|
||||
token (Expo push token) so the user receives push notifications when
|
||||
documents finish processing.
|
||||
|
||||
* ``GET /mobile/devices`` – list registered devices for the current user.
|
||||
|
||||
* ``DELETE /mobile/devices/{device_id}`` – deactivate a device.
|
||||
|
||||
* ``GET /mobile/whoami`` – lightweight profile endpoint for the mobile app
|
||||
to verify authentication state.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.api_tokens import generate_api_token, hash_token
|
||||
from app.auth import require_login
|
||||
from app.database import get_db
|
||||
from app.models import ApiToken, MobileDevice
|
||||
from app.utils.user_scope import get_current_owner_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/mobile", tags=["mobile"])
|
||||
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_owner_id(request: Request) -> str:
|
||||
"""Return the current user's owner ID, raising 401 if unauthenticated."""
|
||||
owner_id = get_current_owner_id(request)
|
||||
if not owner_id:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
return owner_id
|
||||
|
||||
|
||||
CurrentOwner = Annotated[str, Depends(_get_owner_id)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request / Response schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class GenerateTokenRequest(BaseModel):
|
||||
"""Request body for auto-generating a mobile app token."""
|
||||
|
||||
device_name: str = Field(
|
||||
default="Mobile App",
|
||||
min_length=1,
|
||||
max_length=120,
|
||||
description="Human-readable device name used to label the token.",
|
||||
)
|
||||
|
||||
|
||||
class GenerateTokenResponse(BaseModel):
|
||||
"""Response containing the one-time-visible API token."""
|
||||
|
||||
token: str
|
||||
token_id: int
|
||||
name: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class RegisterDeviceRequest(BaseModel):
|
||||
"""Request body for registering a push-notification device token."""
|
||||
|
||||
push_token: str = Field(
|
||||
min_length=1,
|
||||
max_length=512,
|
||||
description="Expo push token (ExponentPushToken[…]) obtained from the mobile app.",
|
||||
)
|
||||
device_name: str | None = Field(
|
||||
default=None,
|
||||
max_length=255,
|
||||
description="Optional human-readable device name (e.g. 'John's iPhone').",
|
||||
)
|
||||
platform: str = Field(
|
||||
default="ios",
|
||||
description="Device platform: 'ios', 'android', or 'web'.",
|
||||
)
|
||||
|
||||
|
||||
class DeviceResponse(BaseModel):
|
||||
"""Serialised MobileDevice record."""
|
||||
|
||||
id: int
|
||||
device_name: str | None
|
||||
platform: str
|
||||
push_token_preview: str
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
last_seen_at: datetime | None
|
||||
|
||||
|
||||
class WhoAmIResponse(BaseModel):
|
||||
"""Lightweight profile response for the mobile app."""
|
||||
|
||||
owner_id: str
|
||||
display_name: str | None
|
||||
email: str | None
|
||||
avatar_url: str | None
|
||||
is_admin: bool
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _device_to_response(device: MobileDevice) -> dict[str, Any]:
|
||||
"""Convert a MobileDevice ORM object to a serialisable dict."""
|
||||
# Show only first 20 chars of the push token for security.
|
||||
token_preview = device.push_token[:20] + "…" if len(device.push_token) > 20 else device.push_token
|
||||
return {
|
||||
"id": device.id,
|
||||
"device_name": device.device_name,
|
||||
"platform": device.platform,
|
||||
"push_token_preview": token_preview,
|
||||
"is_active": device.is_active,
|
||||
"created_at": device.created_at,
|
||||
"last_seen_at": device.last_seen_at,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/generate-token", status_code=status.HTTP_201_CREATED, response_model=GenerateTokenResponse)
|
||||
@require_login
|
||||
async def generate_mobile_token(
|
||||
request: Request,
|
||||
body: GenerateTokenRequest,
|
||||
owner_id: CurrentOwner,
|
||||
db: DbSession,
|
||||
) -> dict[str, Any]:
|
||||
"""Generate a long-lived API token for the mobile app.
|
||||
|
||||
The mobile app calls this endpoint immediately after SSO login to obtain
|
||||
a Bearer token it can store in the secure keychain. The returned token
|
||||
is functionally identical to manually-created API tokens and works with
|
||||
every authenticated endpoint.
|
||||
|
||||
The token is shown **exactly once** in the response; subsequent requests
|
||||
show only the prefix for identification.
|
||||
"""
|
||||
token_name = f"Mobile App – {body.device_name}"
|
||||
plaintext = generate_api_token()
|
||||
token_hash_value = hash_token(plaintext)
|
||||
prefix = plaintext[:12]
|
||||
|
||||
db_token = ApiToken(
|
||||
owner_id=owner_id,
|
||||
name=token_name,
|
||||
token_hash=token_hash_value,
|
||||
token_prefix=prefix,
|
||||
)
|
||||
try:
|
||||
db.add(db_token)
|
||||
db.commit()
|
||||
db.refresh(db_token)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("Failed to create mobile API token for owner_id=%s", owner_id)
|
||||
raise
|
||||
|
||||
logger.info("Mobile API token created: id=%s owner=%s device=%r", db_token.id, owner_id, body.device_name)
|
||||
|
||||
return {
|
||||
"token": plaintext,
|
||||
"token_id": db_token.id,
|
||||
"name": token_name,
|
||||
"created_at": db_token.created_at,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/register-device", status_code=status.HTTP_201_CREATED, response_model=DeviceResponse)
|
||||
@require_login
|
||||
async def register_device(
|
||||
request: Request,
|
||||
body: RegisterDeviceRequest,
|
||||
owner_id: CurrentOwner,
|
||||
db: DbSession,
|
||||
) -> dict[str, Any]:
|
||||
"""Register or refresh a push-notification device token.
|
||||
|
||||
If the same ``push_token`` is already registered for this user the
|
||||
record is reactivated and ``last_seen_at`` is updated rather than
|
||||
creating a duplicate.
|
||||
"""
|
||||
platform = body.platform.lower()
|
||||
if platform not in {"ios", "android", "web"}:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="platform must be one of: ios, android, web",
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Upsert: reuse existing record if the token is already known.
|
||||
existing = (
|
||||
db.query(MobileDevice)
|
||||
.filter(MobileDevice.owner_id == owner_id, MobileDevice.push_token == body.push_token)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
existing.is_active = True
|
||||
existing.last_seen_at = now
|
||||
if body.device_name:
|
||||
existing.device_name = body.device_name
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(existing)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
logger.info("Mobile device refreshed: id=%s owner=%s", existing.id, owner_id)
|
||||
return _device_to_response(existing)
|
||||
|
||||
device = MobileDevice(
|
||||
owner_id=owner_id,
|
||||
device_name=body.device_name,
|
||||
platform=platform,
|
||||
push_token=body.push_token,
|
||||
is_active=True,
|
||||
last_seen_at=now,
|
||||
)
|
||||
try:
|
||||
db.add(device)
|
||||
db.commit()
|
||||
db.refresh(device)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("Failed to register mobile device for owner_id=%s", owner_id)
|
||||
raise
|
||||
|
||||
logger.info("Mobile device registered: id=%s owner=%s platform=%s", device.id, owner_id, platform)
|
||||
return _device_to_response(device)
|
||||
|
||||
|
||||
@router.get("/devices", response_model=list[DeviceResponse])
|
||||
@require_login
|
||||
async def list_devices(
|
||||
request: Request,
|
||||
owner_id: CurrentOwner,
|
||||
db: DbSession,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List all registered push-notification devices for the current user."""
|
||||
devices = (
|
||||
db.query(MobileDevice).filter(MobileDevice.owner_id == owner_id).order_by(MobileDevice.created_at.desc()).all()
|
||||
)
|
||||
return [_device_to_response(d) for d in devices]
|
||||
|
||||
|
||||
@router.delete("/devices/{device_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@require_login
|
||||
async def deactivate_device(
|
||||
request: Request,
|
||||
device_id: int,
|
||||
owner_id: CurrentOwner,
|
||||
db: DbSession,
|
||||
) -> None:
|
||||
"""Deactivate a push-notification device registration.
|
||||
|
||||
The device record is kept for audit purposes but will no longer receive
|
||||
push notifications.
|
||||
"""
|
||||
device = db.get(MobileDevice, device_id)
|
||||
if not device or device.owner_id != owner_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Device not found")
|
||||
|
||||
device.is_active = False
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
logger.info("Mobile device deactivated: id=%s owner=%s", device_id, owner_id)
|
||||
|
||||
|
||||
@router.get("/whoami", response_model=WhoAmIResponse)
|
||||
@require_login
|
||||
async def whoami(
|
||||
request: Request,
|
||||
owner_id: CurrentOwner,
|
||||
db: DbSession,
|
||||
) -> dict[str, Any]:
|
||||
"""Return basic profile information for the authenticated user.
|
||||
|
||||
The mobile app calls this after token exchange to populate the user
|
||||
profile screen and verify that the stored token is still valid.
|
||||
"""
|
||||
from app.auth import get_gravatar_url
|
||||
from app.models import LocalUser, UserProfile
|
||||
|
||||
profile = db.query(UserProfile).filter(UserProfile.user_id == owner_id).first()
|
||||
local_user = db.query(LocalUser).filter(LocalUser.email == owner_id).first()
|
||||
|
||||
display_name: str | None = None
|
||||
email: str | None = None
|
||||
avatar_url: str | None = None
|
||||
is_admin = False
|
||||
|
||||
if profile:
|
||||
display_name = profile.display_name
|
||||
|
||||
if local_user:
|
||||
email = local_user.email
|
||||
is_admin = bool(local_user.is_admin)
|
||||
if not display_name and local_user.display_name:
|
||||
display_name = local_user.display_name
|
||||
elif "@" in owner_id:
|
||||
# SSO users commonly have their email as owner_id
|
||||
email = owner_id
|
||||
|
||||
if email:
|
||||
avatar_url = get_gravatar_url(email)
|
||||
|
||||
return {
|
||||
"owner_id": owner_id,
|
||||
"display_name": display_name,
|
||||
"email": email,
|
||||
"avatar_url": avatar_url,
|
||||
"is_admin": is_admin,
|
||||
}
|
||||
@@ -833,3 +833,37 @@ class ScheduledJob(Base):
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class MobileDevice(Base):
|
||||
"""Registered mobile device for push notifications.
|
||||
|
||||
Stores the push token (Expo push token, FCM token, or APNs token) for a
|
||||
specific user device so that document-processing events can be forwarded
|
||||
as push notifications to the native mobile app.
|
||||
"""
|
||||
|
||||
__tablename__ = "mobile_devices"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# User that owns this device registration.
|
||||
owner_id = Column(String, nullable=False, index=True)
|
||||
|
||||
# Human-readable name the user gave this device (e.g. "John's iPhone").
|
||||
device_name = Column(String(255), nullable=True)
|
||||
|
||||
# Platform: "ios", "android", or "web".
|
||||
platform = Column(String(20), nullable=False, default="ios")
|
||||
|
||||
# Expo push token (ExponentPushToken[…]) or raw FCM/APNs token.
|
||||
push_token = Column(String(512), nullable=False)
|
||||
|
||||
# Whether push notifications are enabled for this device.
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
|
||||
# Timestamps.
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
last_seen_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
__table_args__ = (UniqueConstraint("owner_id", "push_token", name="uq_mobile_device_owner_token"),)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Push notification sender for the DocuElevate mobile app.
|
||||
|
||||
Uses the **Expo Push Notification** service to deliver notifications to both
|
||||
iOS (via APNs) and Android (via FCM) without requiring server-side APNs keys
|
||||
or FCM credentials. The mobile app obtains an ``ExponentPushToken[…]`` at
|
||||
startup and registers it with the backend via the mobile API.
|
||||
|
||||
Reference: https://docs.expo.dev/push-notifications/sending-notifications/
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models import MobileDevice
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send"
|
||||
|
||||
# Maximum tokens per batch request (Expo limit).
|
||||
_EXPO_BATCH_LIMIT = 100
|
||||
|
||||
|
||||
def send_expo_push_notification(
|
||||
tokens: list[str],
|
||||
title: str,
|
||||
body: str,
|
||||
data: dict[str, Any] | None = None,
|
||||
sound: str = "default",
|
||||
badge: int | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Send a push notification to one or more Expo push tokens.
|
||||
|
||||
Args:
|
||||
tokens: List of Expo push tokens (``ExponentPushToken[…]``).
|
||||
title: Notification title shown in the system tray.
|
||||
body: Notification body text.
|
||||
data: Optional JSON-serialisable dict attached to the notification
|
||||
(available in the app via ``notification.request.content.data``).
|
||||
sound: Notification sound. Use ``"default"`` or ``None`` for silent.
|
||||
badge: iOS badge count. Pass ``0`` to clear.
|
||||
|
||||
Returns:
|
||||
List of Expo push receipt dicts (one per token).
|
||||
"""
|
||||
if not tokens:
|
||||
return []
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
|
||||
# Send in batches to stay within Expo's per-request limit.
|
||||
for i in range(0, len(tokens), _EXPO_BATCH_LIMIT):
|
||||
batch = tokens[i : i + _EXPO_BATCH_LIMIT]
|
||||
messages = []
|
||||
for token in batch:
|
||||
msg: dict[str, Any] = {
|
||||
"to": token,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"sound": sound,
|
||||
}
|
||||
if data:
|
||||
msg["data"] = data
|
||||
if badge is not None:
|
||||
msg["badge"] = badge
|
||||
messages.append(msg)
|
||||
|
||||
try:
|
||||
resp = httpx.post(
|
||||
EXPO_PUSH_URL,
|
||||
json=messages,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Accept-Encoding": "gzip, deflate",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
batch_results = payload.get("data", [])
|
||||
results.extend(batch_results)
|
||||
logger.debug("Expo push batch sent: %d tokens, %d results", len(batch), len(batch_results))
|
||||
except httpx.HTTPStatusError as exc:
|
||||
logger.error("Expo push HTTP error: %s – %s", exc.response.status_code, exc.response.text)
|
||||
except Exception:
|
||||
logger.exception("Expo push notification failed for batch starting at index %d", i)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def send_push_to_owner(
|
||||
owner_id: str,
|
||||
title: str,
|
||||
body: str,
|
||||
data: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Look up all active push tokens for *owner_id* and send them a notification.
|
||||
|
||||
This function is safe to call from Celery task workers. Database errors
|
||||
and push failures are logged but never raised so that the caller task is
|
||||
not retried due to a notification failure.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
devices = (
|
||||
db.query(MobileDevice)
|
||||
.filter(
|
||||
MobileDevice.owner_id == owner_id,
|
||||
MobileDevice.is_active.is_(True),
|
||||
MobileDevice.push_token.isnot(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
tokens = [d.push_token for d in devices if d.push_token]
|
||||
except Exception:
|
||||
logger.exception("Failed to query mobile devices for owner_id=%s", owner_id)
|
||||
return
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if not tokens:
|
||||
logger.debug("No active push tokens for owner_id=%s", owner_id)
|
||||
return
|
||||
|
||||
logger.info("Sending push notification to %d device(s) for owner_id=%s", len(tokens), owner_id)
|
||||
send_expo_push_notification(tokens=tokens, title=title, body=body, data=data)
|
||||
@@ -210,6 +210,19 @@ def dispatch_user_notification(
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# 3. Send push notifications to registered mobile devices
|
||||
try:
|
||||
from app.utils.push_notification import send_push_to_owner
|
||||
|
||||
send_push_to_owner(
|
||||
owner_id=owner_id,
|
||||
title=title,
|
||||
body=message,
|
||||
data={"event_type": event_type, "file_id": file_id},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Error sending push notification for owner_id=%s event=%s", owner_id, event_type)
|
||||
|
||||
|
||||
def notify_user_document_processed(owner_id: str, filename: str, file_id: int | None = None) -> None:
|
||||
"""Notify a user that their document was successfully processed."""
|
||||
|
||||
+69
@@ -2063,3 +2063,72 @@ print(response.json())
|
||||
## Further Assistance
|
||||
|
||||
For additional help with the API, please contact our support team or refer to the [Development Guide](../CONTRIBUTING.md).
|
||||
|
||||
## Mobile App API
|
||||
|
||||
The mobile API provides endpoints used by the native iOS and Android app. All endpoints require authentication (Bearer token or active session cookie).
|
||||
|
||||
For full mobile app documentation see [MobileApp.md](./MobileApp.md).
|
||||
|
||||
### POST /api/mobile/generate-token
|
||||
|
||||
Exchange an active web session for a long-lived API token scoped to the mobile app.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "device_name": "John's iPhone" }
|
||||
```
|
||||
|
||||
**Response (201 Created):**
|
||||
```json
|
||||
{
|
||||
"token": "de_AbCdEfGhIjKl...",
|
||||
"token_id": 42,
|
||||
"name": "Mobile App – John's iPhone",
|
||||
"created_at": "2026-03-10T09:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
> The `token` is shown **once only**.
|
||||
|
||||
### POST /api/mobile/register-device
|
||||
|
||||
Register an Expo push token to receive push notifications.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"push_token": "ExponentPushToken[xxxxxx]",
|
||||
"device_name": "John's iPhone",
|
||||
"platform": "ios"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (201 Created):** Device record with `id`, `platform`, `is_active`, `created_at`.
|
||||
|
||||
### GET /api/mobile/devices
|
||||
|
||||
List all registered push-notification devices for the current user.
|
||||
|
||||
**Response (200 OK):** Array of device records.
|
||||
|
||||
### DELETE /api/mobile/devices/{device_id}
|
||||
|
||||
Deactivate a push-notification device. The device will no longer receive push notifications.
|
||||
|
||||
**Response (204 No Content)**
|
||||
|
||||
### GET /api/mobile/whoami
|
||||
|
||||
Return basic profile information for the authenticated user.
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"owner_id": "john@example.com",
|
||||
"display_name": "John Doe",
|
||||
"email": "john@example.com",
|
||||
"avatar_url": "https://www.gravatar.com/avatar/...",
|
||||
"is_admin": false
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
# Mobile App
|
||||
|
||||
DocuElevate includes a native mobile application for iOS and Android built with **React Native** and **Expo**. The app allows users to capture documents with the device camera, pick files from the device storage, and receive push notifications when documents finish processing.
|
||||
|
||||
## Features
|
||||
|
||||
| Feature | iOS | Android |
|
||||
|---------|-----|---------|
|
||||
| SSO login (OAuth2) | ✅ | ✅ |
|
||||
| Local / basic auth login | ✅ | ✅ |
|
||||
| Auto-generated API token | ✅ | ✅ |
|
||||
| Camera capture → upload | ✅ | ✅ |
|
||||
| File picker upload | ✅ | ✅ |
|
||||
| Share Sheet / Share Intent | ✅ | ✅ |
|
||||
| Push notifications | ✅ | ✅ |
|
||||
| Document list | ✅ | ✅ |
|
||||
| Dark mode | ✅ | ✅ |
|
||||
|
||||
## Getting Started (Development)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18 or later
|
||||
- [Expo CLI](https://docs.expo.dev/get-started/installation/): `npm install -g @expo/cli`
|
||||
- [Expo Go](https://expo.dev/client) app on your iOS or Android device (for development)
|
||||
- A running DocuElevate server reachable from your device
|
||||
|
||||
### Run in development mode
|
||||
|
||||
```bash
|
||||
cd mobile
|
||||
npm install
|
||||
npx expo start
|
||||
```
|
||||
|
||||
Scan the QR code with **Expo Go** on your device. On iOS you can also use the Camera app.
|
||||
|
||||
## Building for Production
|
||||
|
||||
DocuElevate uses **Expo Application Services (EAS)** to produce App Store / Play Store binaries.
|
||||
|
||||
```bash
|
||||
# Install EAS CLI globally
|
||||
npm install -g eas-cli
|
||||
|
||||
# Authenticate with Expo
|
||||
eas login
|
||||
|
||||
# Build for iOS (requires Apple Developer account)
|
||||
eas build --platform ios
|
||||
|
||||
# Build for Android
|
||||
eas build --platform android
|
||||
```
|
||||
|
||||
See the [EAS Build documentation](https://docs.expo.dev/build/introduction/) for full setup instructions.
|
||||
|
||||
## Authentication
|
||||
|
||||
### SSO Login Flow
|
||||
|
||||
The mobile app uses the server's existing OAuth2/SSO setup:
|
||||
|
||||
1. User enters the DocuElevate server URL on the login screen.
|
||||
2. The app opens `<server>/login?mobile=1&redirect_uri=docuelevate://callback` in the **system browser** (Safari / Chrome).
|
||||
3. The user authenticates via SSO or local credentials.
|
||||
4. The server redirects back to `docuelevate://callback`.
|
||||
5. The app calls `POST /api/mobile/generate-token` to exchange the session for a **long-lived API token**.
|
||||
6. The token is stored securely in the device's keychain (`expo-secure-store`).
|
||||
|
||||
### Auto-generated Mobile Token
|
||||
|
||||
When the mobile app completes login it automatically creates a named API token (`"Mobile App – <device name>"`) via `POST /api/mobile/generate-token`. This token:
|
||||
|
||||
- Works identically to tokens created manually in the web UI.
|
||||
- Is shown in the **API Tokens** page (`/api-tokens`) and can be revoked there.
|
||||
- Is stored in the device's secure keychain, never in plain storage.
|
||||
|
||||
## Push Notifications
|
||||
|
||||
Push notifications are delivered via the **Expo Push Notification** service, which routes through Apple Push Notification service (APNs) for iOS and Firebase Cloud Messaging (FCM) for Android.
|
||||
|
||||
**No server-side APNs/FCM credentials are required** – Expo's servers handle the provider integration.
|
||||
|
||||
### How it works
|
||||
|
||||
1. After login, the app requests notification permission from the operating system.
|
||||
2. If granted, the app obtains an **Expo Push Token** (`ExponentPushToken[…]`).
|
||||
3. The token is registered with the backend via `POST /api/mobile/register-device`.
|
||||
4. When a document finishes processing, the server sends a push notification to all registered devices for that user.
|
||||
|
||||
### Managing registered devices
|
||||
|
||||
Users can see and remove their registered devices from the **Profile** tab in the app, or via the API:
|
||||
|
||||
```bash
|
||||
# List registered devices
|
||||
curl -H "Authorization: Bearer <token>" https://your-server/api/mobile/devices
|
||||
|
||||
# Remove a device
|
||||
curl -X DELETE -H "Authorization: Bearer <token>" https://your-server/api/mobile/devices/<id>
|
||||
```
|
||||
|
||||
## Uploading Documents
|
||||
|
||||
### Camera Capture
|
||||
|
||||
1. Open the **Upload** tab.
|
||||
2. Tap **Camera**.
|
||||
3. Point the camera at the document and take a photo.
|
||||
4. The image is immediately uploaded and queued for processing.
|
||||
|
||||
### File Picker
|
||||
|
||||
1. Open the **Upload** tab.
|
||||
2. Tap **File Picker**.
|
||||
3. Browse to and select one or more files (PDF, DOCX, images, etc.).
|
||||
4. Files are uploaded and queued for processing.
|
||||
|
||||
### Share Sheet (iOS) / Share Intent (Android)
|
||||
|
||||
The app registers itself as a share target so any file can be sent directly to DocuElevate from another app:
|
||||
|
||||
1. Open a file in Files, Mail, Safari, or any other app.
|
||||
2. Tap the **Share** button (iOS) or **Share** (Android).
|
||||
3. Find and tap **DocuElevate** in the share sheet.
|
||||
4. The file is immediately uploaded.
|
||||
|
||||
> **Note:** The app must be installed on the device for it to appear in the share sheet.
|
||||
|
||||
## Mobile API Endpoints
|
||||
|
||||
The backend exposes a dedicated `/api/mobile/` namespace:
|
||||
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|--------|----------|------|-------------|
|
||||
| `POST` | `/api/mobile/generate-token` | Session | Exchange SSO session for API token |
|
||||
| `POST` | `/api/mobile/register-device` | Bearer | Register Expo push token |
|
||||
| `GET` | `/api/mobile/devices` | Bearer | List registered devices |
|
||||
| `DELETE` | `/api/mobile/devices/{id}` | Bearer | Deactivate a device |
|
||||
| `GET` | `/api/mobile/whoami` | Bearer | Get current user profile |
|
||||
|
||||
All other API endpoints (file upload, file listing, etc.) work with Bearer token authentication.
|
||||
|
||||
### POST /api/mobile/generate-token
|
||||
|
||||
Exchanges an active web session (cookie) for a permanent API token suitable for use in the mobile app.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "device_name": "John's iPhone" }
|
||||
```
|
||||
|
||||
**Response (201):**
|
||||
```json
|
||||
{
|
||||
"token": "de_AbCdEfGhIjKl...",
|
||||
"token_id": 42,
|
||||
"name": "Mobile App – John's iPhone",
|
||||
"created_at": "2026-03-10T09:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
> ⚠️ The `token` value is returned **once only**. Store it in the device's secure keychain immediately.
|
||||
|
||||
### POST /api/mobile/register-device
|
||||
|
||||
Registers an Expo push token for the authenticated user.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"push_token": "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]",
|
||||
"device_name": "John's iPhone",
|
||||
"platform": "ios"
|
||||
}
|
||||
```
|
||||
|
||||
Supported platforms: `ios`, `android`, `web`.
|
||||
|
||||
Re-registering the same token is safe (idempotent).
|
||||
|
||||
### GET /api/mobile/whoami
|
||||
|
||||
Returns the current user's profile.
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"owner_id": "john@example.com",
|
||||
"display_name": "John Doe",
|
||||
"email": "john@example.com",
|
||||
"avatar_url": "https://www.gravatar.com/avatar/...",
|
||||
"is_admin": false
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
No server-side configuration is required to enable the mobile app. The Expo push notification routing does not need FCM or APNs credentials on the server.
|
||||
|
||||
If you wish to use **direct FCM/APNs** without Expo's relay, replace the `send_expo_push_notification` function in `app/utils/push_notification.py` with your own implementation.
|
||||
|
||||
## Project Structure (mobile/)
|
||||
|
||||
```
|
||||
mobile/
|
||||
├── App.tsx # Root component
|
||||
├── app.json # Expo/EAS configuration
|
||||
├── eas.json # EAS Build profiles
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
└── src/
|
||||
├── context/
|
||||
│ └── AuthContext.tsx # Auth state + SSO login flow
|
||||
├── hooks/
|
||||
│ └── usePushNotifications.ts # Push token registration
|
||||
├── screens/
|
||||
│ ├── LoginScreen.tsx # Server URL + SSO button
|
||||
│ ├── UploadScreen.tsx # Camera capture + file picker
|
||||
│ ├── FilesScreen.tsx # Processed document list
|
||||
│ └── ProfileScreen.tsx # User profile + sign out
|
||||
└── services/
|
||||
└── api.ts # DocuElevate REST API client
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Authentication was cancelled or failed"
|
||||
|
||||
- Ensure the server URL is correct (including `https://`).
|
||||
- Verify the server is reachable from your device's network.
|
||||
- Confirm that `AUTH_ENABLED=True` on the server.
|
||||
|
||||
### Push notifications not arriving
|
||||
|
||||
1. Check that the app has notification permission (Settings → DocuElevate → Notifications).
|
||||
2. Verify the device is registered: `GET /api/mobile/devices`.
|
||||
3. Ensure the server can reach `https://exp.host` (outbound HTTPS on port 443).
|
||||
4. On Android, add `google-services.json` to the `mobile/` directory if you are building your own binary.
|
||||
|
||||
### "Connection refused" or timeout
|
||||
|
||||
- Verify that the DocuElevate server is running and accessible.
|
||||
- Ensure the server's `EXTERNAL_HOSTNAME` or reverse proxy is configured correctly.
|
||||
- Check that the server accepts CORS requests from `docuelevate://`.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [API Documentation](./API.md)
|
||||
- [Configuration Guide](./ConfigurationGuide.md)
|
||||
- [Deployment Guide](./DeploymentGuide.md)
|
||||
@@ -24,6 +24,7 @@ from app.models import ( # noqa: F401
|
||||
DocumentMetadata,
|
||||
FileProcessingStep,
|
||||
FileRecord,
|
||||
MobileDevice,
|
||||
ProcessingLog,
|
||||
SavedSearch,
|
||||
SettingsAuditLog,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Add mobile_devices table for push notification device registration.
|
||||
|
||||
Revision ID: 027_add_mobile_devices
|
||||
Revises: 026_add_scheduled_jobs
|
||||
Create Date: 2026-03-10
|
||||
"""
|
||||
|
||||
from typing import Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "027_add_mobile_devices"
|
||||
down_revision: Union[str, None] = "026_add_scheduled_jobs"
|
||||
depends_on: Union[str, None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create mobile_devices table."""
|
||||
op.create_table(
|
||||
"mobile_devices",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("owner_id", sa.String(), nullable=False),
|
||||
sa.Column("device_name", sa.String(255), nullable=True),
|
||||
sa.Column("platform", sa.String(20), nullable=False, server_default="ios"),
|
||||
sa.Column("push_token", sa.String(512), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("owner_id", "push_token", name="uq_mobile_device_owner_token"),
|
||||
)
|
||||
op.create_index("ix_mobile_devices_id", "mobile_devices", ["id"])
|
||||
op.create_index("ix_mobile_devices_owner_id", "mobile_devices", ["owner_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop mobile_devices table."""
|
||||
op.drop_index("ix_mobile_devices_owner_id", table_name="mobile_devices")
|
||||
op.drop_index("ix_mobile_devices_id", table_name="mobile_devices")
|
||||
op.drop_table("mobile_devices")
|
||||
@@ -0,0 +1,21 @@
|
||||
node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
ios/
|
||||
android/
|
||||
.env
|
||||
google-services.json
|
||||
GoogleService-Info.plist
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
.idea/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* App.tsx – root component for the DocuElevate mobile app.
|
||||
*
|
||||
* Wraps the entire app in the AuthProvider and renders either the login
|
||||
* screen (unauthenticated) or the main tab navigator (authenticated).
|
||||
* Push notification registration is handled by the usePushNotifications hook.
|
||||
*/
|
||||
|
||||
import { NavigationContainer } from "@react-navigation/native";
|
||||
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
||||
import React from "react";
|
||||
import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import { AuthProvider, useAuth } from "./src/context/AuthContext";
|
||||
import { usePushNotifications } from "./src/hooks/usePushNotifications";
|
||||
import FilesScreen from "./src/screens/FilesScreen";
|
||||
import LoginScreen from "./src/screens/LoginScreen";
|
||||
import ProfileScreen from "./src/screens/ProfileScreen";
|
||||
import UploadScreen from "./src/screens/UploadScreen";
|
||||
|
||||
const Tab = createBottomTabNavigator();
|
||||
|
||||
function TabNavigator() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
usePushNotifications(isAuthenticated);
|
||||
|
||||
return (
|
||||
<Tab.Navigator
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: "#1e40af",
|
||||
tabBarInactiveTintColor: "#9ca3af",
|
||||
tabBarStyle: {
|
||||
borderTopColor: "#e5e7eb",
|
||||
backgroundColor: "#ffffff",
|
||||
},
|
||||
headerStyle: {
|
||||
backgroundColor: "#1e40af",
|
||||
},
|
||||
headerTintColor: "#ffffff",
|
||||
headerTitleStyle: {
|
||||
fontWeight: "700",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tab.Screen
|
||||
name="Upload"
|
||||
component={UploadScreen}
|
||||
options={{
|
||||
title: "Upload",
|
||||
tabBarLabel: "Upload",
|
||||
tabBarIcon: ({ color }) => (
|
||||
<Text style={{ fontSize: 20, color }}>⬆️</Text>
|
||||
),
|
||||
headerTitle: "DocuElevate",
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Files"
|
||||
component={FilesScreen}
|
||||
options={{
|
||||
title: "Files",
|
||||
tabBarLabel: "Files",
|
||||
tabBarIcon: ({ color }) => (
|
||||
<Text style={{ fontSize: 20, color }}>📄</Text>
|
||||
),
|
||||
headerTitle: "My Documents",
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Profile"
|
||||
component={ProfileScreen}
|
||||
options={{
|
||||
title: "Profile",
|
||||
tabBarLabel: "Profile",
|
||||
tabBarIcon: ({ color }) => (
|
||||
<Text style={{ fontSize: 20, color }}>👤</Text>
|
||||
),
|
||||
headerTitle: "Profile",
|
||||
}}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
);
|
||||
}
|
||||
|
||||
function AppContent() {
|
||||
const { isLoading, isAuthenticated } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.loading}>
|
||||
<ActivityIndicator size="large" color="#1e40af" />
|
||||
<Text style={styles.loadingText}>Loading…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavigationContainer>
|
||||
{isAuthenticated ? <TabNavigator /> : <LoginScreen />}
|
||||
</NavigationContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<AuthProvider>
|
||||
<AppContent />
|
||||
</AuthProvider>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
loading: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#f9fafb",
|
||||
gap: 12,
|
||||
},
|
||||
loadingText: {
|
||||
color: "#6b7280",
|
||||
fontSize: 15,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
# DocuElevate Mobile App
|
||||
|
||||
Native mobile application for DocuElevate, built with **React Native** and **Expo** for both iOS (primary) and Android.
|
||||
|
||||
## Features
|
||||
|
||||
- 🔐 **SSO Login** – authenticate via your DocuElevate server's OAuth2/SSO provider; an API token is auto-generated and stored securely in the device keychain
|
||||
- 📷 **Camera Capture** – scan documents directly with the device camera
|
||||
- 📄 **File Picker** – upload PDFs, images, and Office documents from the device's Files app
|
||||
- 🔗 **Share Extension** – send files from any app directly to DocuElevate via the iOS/Android share sheet
|
||||
- 🔔 **Push Notifications** – receive real-time push notifications when documents finish processing (via Expo push notifications)
|
||||
- 📂 **Document List** – browse and search your processed documents
|
||||
- 👤 **Profile** – view account details and sign out
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 18+
|
||||
- Expo CLI (`npm install -g @expo/cli`)
|
||||
- Expo Go app on device (for development) **or** Expo Application Services (EAS) for production builds
|
||||
- An Expo account: <https://expo.dev/>
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# 1. Install dependencies
|
||||
cd mobile
|
||||
npm install
|
||||
|
||||
# 2. Start the development server
|
||||
npx expo start
|
||||
```
|
||||
|
||||
Scan the QR code with **Expo Go** on your iOS or Android device.
|
||||
|
||||
## Building
|
||||
|
||||
DocuElevate uses **EAS Build** for production binaries.
|
||||
|
||||
```bash
|
||||
# Install EAS CLI
|
||||
npm install -g eas-cli
|
||||
|
||||
# Log in to Expo
|
||||
eas login
|
||||
|
||||
# Configure your project (one-time)
|
||||
eas init
|
||||
|
||||
# Build for iOS
|
||||
eas build --platform ios
|
||||
|
||||
# Build for Android
|
||||
eas build --platform android
|
||||
|
||||
# Build for both
|
||||
eas build --platform all
|
||||
```
|
||||
|
||||
### iOS-specific
|
||||
|
||||
- An Apple Developer account is required for TestFlight and App Store distribution
|
||||
- Update `eas.json` with your `appleId`, `ascAppId`, and `appleTeamId`
|
||||
- Camera, photo library, and push notification usage descriptions are configured in `app.json`
|
||||
|
||||
### Android-specific
|
||||
|
||||
- Add a `google-services.json` file (from Firebase Console) to the `mobile/` directory for push notification support
|
||||
- Update `eas.json` with the path to your Google Play service account key
|
||||
|
||||
## Configuration
|
||||
|
||||
No code changes are needed to point the app at a different server. The server URL is entered by the user on the login screen and stored in the device's secure store.
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
1. User enters the DocuElevate server URL on the login screen
|
||||
2. The app opens the server's `/login?mobile=1&redirect_uri=docuelevate://callback` URL in the system browser
|
||||
3. The user authenticates (SSO / local login)
|
||||
4. The server redirects back to `docuelevate://callback`
|
||||
5. The app exchanges the browser session for a permanent API token via `POST /api/mobile/generate-token`
|
||||
6. The token is stored in the device's secure keychain (`expo-secure-store`)
|
||||
|
||||
## Push Notifications
|
||||
|
||||
The app uses **Expo Push Notifications** which route through Expo's servers to APNs (iOS) and FCM (Android) – no server-side APNs/FCM credentials are needed.
|
||||
|
||||
The Expo push token is sent to the backend after login via `POST /api/mobile/register-device` and the server uses it to deliver notifications when documents are processed.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
mobile/
|
||||
├── App.tsx # Root component
|
||||
├── app.json # Expo configuration
|
||||
├── eas.json # EAS Build configuration
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
└── src/
|
||||
├── context/
|
||||
│ └── AuthContext.tsx # Authentication state management
|
||||
├── hooks/
|
||||
│ └── usePushNotifications.ts # Push notification registration
|
||||
├── screens/
|
||||
│ ├── LoginScreen.tsx # SSO login
|
||||
│ ├── UploadScreen.tsx # Camera capture + file picker
|
||||
│ ├── FilesScreen.tsx # Document list
|
||||
│ └── ProfileScreen.tsx # User profile + sign out
|
||||
└── services/
|
||||
└── api.ts # DocuElevate API client
|
||||
```
|
||||
|
||||
## Share Extension (iOS)
|
||||
|
||||
The app registers the `docuelevate://` URL scheme and the `com.docuelevate.app` bundle identifier. To enable the share sheet:
|
||||
|
||||
1. Ensure the app is installed on the device
|
||||
2. Open any file in Files, Mail, Safari, etc.
|
||||
3. Tap the share icon → find **DocuElevate** in the share sheet
|
||||
4. The file is uploaded immediately
|
||||
|
||||
Android uses a similar intent filter configured in `app.json`.
|
||||
|
||||
## Backend API
|
||||
|
||||
The mobile app uses the following backend endpoints:
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|----------|-------------------------------------|---------------------------------------|
|
||||
| `POST` | `/api/mobile/generate-token` | Exchange SSO session for API token |
|
||||
| `POST` | `/api/mobile/register-device` | Register Expo push token |
|
||||
| `GET` | `/api/mobile/devices` | List registered devices |
|
||||
| `DELETE` | `/api/mobile/devices/{id}` | Deactivate device registration |
|
||||
| `GET` | `/api/mobile/whoami` | Get current user profile |
|
||||
| `POST` | `/api/ui-upload` | Upload file for processing |
|
||||
| `GET` | `/api/files` | List processed documents |
|
||||
|
||||
Authentication uses `Authorization: Bearer <api_token>` on all requests.
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"name": "DocuElevate",
|
||||
"slug": "docuelevate",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#1e40af"
|
||||
},
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.docuelevate.app",
|
||||
"infoPlist": {
|
||||
"NSCameraUsageDescription": "DocuElevate uses the camera 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"]
|
||||
},
|
||||
"buildNumber": "1"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#1e40af"
|
||||
},
|
||||
"package": "com.docuelevate.app",
|
||||
"permissions": [
|
||||
"CAMERA",
|
||||
"READ_EXTERNAL_STORAGE",
|
||||
"WRITE_EXTERNAL_STORAGE",
|
||||
"RECEIVE_BOOT_COMPLETED",
|
||||
"VIBRATE"
|
||||
],
|
||||
"versionCode": 1,
|
||||
"googleServicesFile": "./google-services.json"
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
"icon": "./assets/notification-icon.png",
|
||||
"color": "#1e40af",
|
||||
"sounds": ["./assets/notification-sound.wav"]
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
"cameraPermission": "DocuElevate uses the camera to capture documents for upload."
|
||||
}
|
||||
],
|
||||
"expo-document-picker",
|
||||
"expo-secure-store",
|
||||
"expo-sharing"
|
||||
],
|
||||
"scheme": "docuelevate",
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "YOUR_EAS_PROJECT_ID"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ["babel-preset-expo"],
|
||||
plugins: [
|
||||
[
|
||||
"module-resolver",
|
||||
{
|
||||
root: ["./"],
|
||||
alias: {
|
||||
"@": "./src",
|
||||
},
|
||||
},
|
||||
],
|
||||
"react-native-reanimated/plugin",
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 5.9.0"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"simulator": false
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {
|
||||
"ios": {
|
||||
"appleId": "YOUR_APPLE_ID",
|
||||
"ascAppId": "YOUR_APP_STORE_CONNECT_APP_ID",
|
||||
"appleTeamId": "YOUR_APPLE_TEAM_ID"
|
||||
},
|
||||
"android": {
|
||||
"serviceAccountKeyPath": "./google-play-service-account.json",
|
||||
"track": "production"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "docuelevate-mobile",
|
||||
"version": "1.0.0",
|
||||
"description": "DocuElevate native mobile app (iOS and Android)",
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"type-check": "tsc --noEmit",
|
||||
"build:ios": "eas build --platform ios",
|
||||
"build:android": "eas build --platform android",
|
||||
"build:all": "eas build --platform all",
|
||||
"submit:ios": "eas submit --platform ios",
|
||||
"submit:android": "eas submit --platform android"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^14.0.0",
|
||||
"@react-native-async-storage/async-storage": "1.23.1",
|
||||
"@react-navigation/bottom-tabs": "^6.6.1",
|
||||
"@react-navigation/native": "^6.1.18",
|
||||
"@react-navigation/native-stack": "^6.11.0",
|
||||
"expo": "~51.0.0",
|
||||
"expo-auth-session": "~5.5.2",
|
||||
"expo-camera": "~15.0.16",
|
||||
"expo-constants": "~16.0.2",
|
||||
"expo-crypto": "~13.0.2",
|
||||
"expo-document-picker": "~12.0.2",
|
||||
"expo-file-system": "~17.0.1",
|
||||
"expo-image-manipulator": "~12.0.5",
|
||||
"expo-image-picker": "~15.0.7",
|
||||
"expo-linking": "~6.3.1",
|
||||
"expo-notifications": "~0.28.15",
|
||||
"expo-router": "~3.5.23",
|
||||
"expo-secure-store": "~13.0.2",
|
||||
"expo-sharing": "~12.0.1",
|
||||
"expo-splash-screen": "~0.27.5",
|
||||
"expo-status-bar": "~1.12.1",
|
||||
"expo-web-browser": "~13.0.3",
|
||||
"react": "18.2.0",
|
||||
"react-native": "0.74.5",
|
||||
"react-native-safe-area-context": "4.10.5",
|
||||
"react-native-screens": "3.31.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.0",
|
||||
"@types/react": "~18.2.79",
|
||||
"@types/react-native": "^0.73.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-expo": "~7.0.0",
|
||||
"typescript": "^5.3.0"
|
||||
},
|
||||
"private": true,
|
||||
"expo": {
|
||||
"doctor": {
|
||||
"reactNativeDirectoryCheck": {
|
||||
"exclude": ["@react-navigation/bottom-tabs"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Authentication context for the DocuElevate mobile app.
|
||||
*
|
||||
* Manages the lifecycle of the stored API token and user profile. The SSO
|
||||
* login flow uses expo-auth-session to open the server's OAuth page in the
|
||||
* system browser; on return the redirect URL carries a one-time code that is
|
||||
* exchanged for a session cookie, which is then traded for a permanent API
|
||||
* token via POST /api/mobile/generate-token.
|
||||
*/
|
||||
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import * as WebBrowser from "expo-web-browser";
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
SECURE_STORE_API_TOKEN_KEY,
|
||||
SECURE_STORE_BASE_URL_KEY,
|
||||
SECURE_STORE_OWNER_ID_KEY,
|
||||
api,
|
||||
type WhoAmIResponse,
|
||||
} from "../services/api";
|
||||
|
||||
WebBrowser.maybeCompleteAuthSession();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AuthState {
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
user: WhoAmIResponse | null;
|
||||
baseUrl: string;
|
||||
signIn: (serverUrl: string) => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
setToken: (token: string) => Promise<void>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AuthContext = createContext<AuthState>({
|
||||
isLoading: true,
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
baseUrl: "",
|
||||
signIn: async () => {},
|
||||
signOut: async () => {},
|
||||
setToken: async () => {},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [user, setUser] = useState<WhoAmIResponse | null>(null);
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
|
||||
// On mount: restore persisted session
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const storedUrl = await SecureStore.getItemAsync(SECURE_STORE_BASE_URL_KEY);
|
||||
const storedToken = await SecureStore.getItemAsync(SECURE_STORE_API_TOKEN_KEY);
|
||||
|
||||
if (storedUrl && storedToken) {
|
||||
await api.init(storedUrl);
|
||||
setBaseUrl(storedUrl);
|
||||
// Verify token is still valid
|
||||
const profile = await api.whoAmI();
|
||||
setUser(profile);
|
||||
setIsAuthenticated(true);
|
||||
}
|
||||
} catch {
|
||||
// Token expired or server unavailable – clear stored credentials
|
||||
await SecureStore.deleteItemAsync(SECURE_STORE_API_TOKEN_KEY);
|
||||
await SecureStore.deleteItemAsync(SECURE_STORE_OWNER_ID_KEY);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const setToken = useCallback(async (token: string) => {
|
||||
await SecureStore.setItemAsync(SECURE_STORE_API_TOKEN_KEY, token);
|
||||
const profile = await api.whoAmI();
|
||||
setUser(profile);
|
||||
await SecureStore.setItemAsync(SECURE_STORE_OWNER_ID_KEY, profile.owner_id);
|
||||
setIsAuthenticated(true);
|
||||
}, []);
|
||||
|
||||
const signIn = useCallback(
|
||||
async (serverUrl: string) => {
|
||||
const cleanUrl = serverUrl.replace(/\/$/, "");
|
||||
await api.init(cleanUrl);
|
||||
setBaseUrl(cleanUrl);
|
||||
|
||||
// Open the web login page in the system browser. The user authenticates
|
||||
// via SSO or local credentials, then the app deep-link (docuelevate://callback)
|
||||
// is triggered. The WebBrowser.openAuthSessionAsync handles the redirect
|
||||
// back to the app.
|
||||
const result = await WebBrowser.openAuthSessionAsync(
|
||||
`${cleanUrl}/login?mobile=1&redirect_uri=docuelevate://callback`,
|
||||
"docuelevate://callback"
|
||||
);
|
||||
|
||||
if (result.type !== "success") {
|
||||
throw new Error("Authentication was cancelled or failed");
|
||||
}
|
||||
|
||||
// Parse the token from the redirect URL if the server appended it,
|
||||
// otherwise hit the generate-token endpoint (session cookie is carried
|
||||
// by the WebBrowser).
|
||||
const url = new URL(result.url);
|
||||
const inlineToken = url.searchParams.get("token");
|
||||
|
||||
if (inlineToken) {
|
||||
await setToken(inlineToken);
|
||||
} else {
|
||||
// The server set a session cookie during the browser session; exchange
|
||||
// it for a persistent API token.
|
||||
const deviceInfo = await _getDeviceName();
|
||||
const tokenResp = await api.generateMobileToken(deviceInfo);
|
||||
await setToken(tokenResp.token);
|
||||
}
|
||||
},
|
||||
[setToken]
|
||||
);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
await SecureStore.deleteItemAsync(SECURE_STORE_API_TOKEN_KEY);
|
||||
await SecureStore.deleteItemAsync(SECURE_STORE_OWNER_ID_KEY);
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
isLoading,
|
||||
isAuthenticated,
|
||||
user,
|
||||
baseUrl,
|
||||
signIn,
|
||||
signOut,
|
||||
setToken,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useAuth(): AuthState {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function _getDeviceName(): Promise<string> {
|
||||
try {
|
||||
const Constants = await import("expo-constants");
|
||||
return (
|
||||
Constants.default.deviceName ||
|
||||
Constants.default.expoConfig?.name ||
|
||||
"Mobile App"
|
||||
);
|
||||
} catch {
|
||||
return "Mobile App";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* usePushNotifications – register the device for push notifications.
|
||||
*
|
||||
* Requests the user's permission for notifications, obtains an Expo push
|
||||
* token, and registers it with the DocuElevate backend via
|
||||
* POST /api/mobile/register-device.
|
||||
*
|
||||
* This hook should be called once from the root component after the user has
|
||||
* successfully authenticated.
|
||||
*/
|
||||
|
||||
import Constants from "expo-constants";
|
||||
import * as Device from "expo-device";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import api from "../services/api";
|
||||
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: true,
|
||||
}),
|
||||
});
|
||||
|
||||
export function usePushNotifications(isAuthenticated: boolean) {
|
||||
const notificationListener = useRef<Notifications.Subscription | null>(null);
|
||||
const responseListener = useRef<Notifications.Subscription | null>(null);
|
||||
|
||||
const registerForPushNotifications = useCallback(async () => {
|
||||
if (!Device.isDevice) {
|
||||
// Push tokens are not available in simulators.
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.OS === "android") {
|
||||
await Notifications.setNotificationChannelAsync("default", {
|
||||
name: "DocuElevate",
|
||||
importance: Notifications.AndroidImportance.MAX,
|
||||
vibrationPattern: [0, 250, 250, 250],
|
||||
lightColor: "#1e40af",
|
||||
});
|
||||
}
|
||||
|
||||
const { status: existingStatus } = await Notifications.getPermissionsAsync();
|
||||
let finalStatus = existingStatus;
|
||||
|
||||
if (existingStatus !== "granted") {
|
||||
const { status } = await Notifications.requestPermissionsAsync();
|
||||
finalStatus = status;
|
||||
}
|
||||
|
||||
if (finalStatus !== "granted") {
|
||||
// User declined – no push notifications
|
||||
return;
|
||||
}
|
||||
|
||||
let projectId: string | undefined;
|
||||
try {
|
||||
projectId =
|
||||
Constants.expoConfig?.extra?.eas?.projectId ??
|
||||
Constants.easConfig?.projectId;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const tokenData = await Notifications.getExpoPushTokenAsync(
|
||||
projectId ? { projectId } : undefined
|
||||
);
|
||||
|
||||
const pushToken = tokenData.data;
|
||||
const platform = Platform.OS as "ios" | "android" | "web";
|
||||
|
||||
let deviceName = "Mobile App";
|
||||
try {
|
||||
deviceName = Device.modelName ?? Device.deviceName ?? "Mobile App";
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
await api.registerDevice({ push_token: pushToken, device_name: deviceName, platform });
|
||||
} catch {
|
||||
// Registration failure is non-fatal – the app still works without push.
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
registerForPushNotifications();
|
||||
|
||||
// Listen for incoming notifications while app is foregrounded
|
||||
notificationListener.current = Notifications.addNotificationReceivedListener((notification) => {
|
||||
console.log("Notification received:", notification.request.content.title);
|
||||
});
|
||||
|
||||
// Listen for user taps on notifications
|
||||
responseListener.current = Notifications.addNotificationResponseReceivedListener((response) => {
|
||||
const data = response.notification.request.content.data as Record<string, unknown>;
|
||||
// Navigate to file detail if file_id is present
|
||||
if (data?.file_id) {
|
||||
console.log("User tapped notification for file:", data.file_id);
|
||||
// Navigation would be wired up by the caller via a callback prop
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
notificationListener.current?.remove();
|
||||
responseListener.current?.remove();
|
||||
};
|
||||
}, [isAuthenticated, registerForPushNotifications]);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* FilesScreen – list of documents processed by DocuElevate.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import type { FileRecord } from "../services/api";
|
||||
import api from "../services/api";
|
||||
|
||||
function formatBytes(bytes: number | null): string {
|
||||
if (bytes === null || bytes === undefined) return "–";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function statusEmoji(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
processed: "✅",
|
||||
processing: "⚙️",
|
||||
queued: "⏳",
|
||||
failed: "❌",
|
||||
uploaded: "⬆️",
|
||||
};
|
||||
return map[status?.toLowerCase()] ?? "📄";
|
||||
}
|
||||
|
||||
export default function FilesScreen() {
|
||||
const [files, setFiles] = useState<FileRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchFiles = useCallback(
|
||||
async (pageNum: number, replace: boolean) => {
|
||||
try {
|
||||
const data = await api.listFiles(pageNum, 20);
|
||||
if (replace) {
|
||||
setFiles(data);
|
||||
} else {
|
||||
setFiles((prev) => [...prev, ...data]);
|
||||
}
|
||||
setHasMore(data.length === 20);
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load files");
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
await fetchFiles(1, true);
|
||||
setLoading(false);
|
||||
})();
|
||||
}, [fetchFiles]);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
setPage(1);
|
||||
await fetchFiles(1, true);
|
||||
setRefreshing(false);
|
||||
}, [fetchFiles]);
|
||||
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
if (!hasMore || loading || refreshing) return;
|
||||
const next = page + 1;
|
||||
setPage(next);
|
||||
await fetchFiles(next, false);
|
||||
}, [fetchFiles, hasMore, loading, page, refreshing]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator size="large" color="#1e40af" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
<Pressable style={styles.retryButton} onPress={handleRefresh}>
|
||||
<Text style={styles.retryText}>Retry</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
style={styles.list}
|
||||
data={files}
|
||||
keyExtractor={(item) => String(item.id)}
|
||||
contentContainerStyle={styles.listContent}
|
||||
renderItem={({ item }) => <FileRow file={item} />}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||
}
|
||||
onEndReached={handleLoadMore}
|
||||
onEndReachedThreshold={0.4}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyEmoji}>📂</Text>
|
||||
<Text style={styles.emptyText}>No documents yet.</Text>
|
||||
<Text style={styles.emptyHint}>
|
||||
Upload a document from the Upload tab to get started.
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
ListFooterComponent={
|
||||
hasMore && files.length > 0 ? (
|
||||
<ActivityIndicator color="#1e40af" style={{ marginVertical: 16 }} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FileRow({ file }: { file: FileRecord }) {
|
||||
return (
|
||||
<View style={rowStyles.row}>
|
||||
<Text style={rowStyles.icon}>{statusEmoji(file.status)}</Text>
|
||||
<View style={rowStyles.info}>
|
||||
<Text style={rowStyles.filename} numberOfLines={1}>
|
||||
{file.filename}
|
||||
</Text>
|
||||
<Text style={rowStyles.meta}>
|
||||
{formatDate(file.created_at)} · {formatBytes(file.file_size)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={rowStyles.status}>{file.status}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
list: { flex: 1, backgroundColor: "#f9fafb" },
|
||||
listContent: { padding: 16 },
|
||||
center: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#f9fafb",
|
||||
padding: 24,
|
||||
},
|
||||
errorText: { color: "#dc2626", fontSize: 15, textAlign: "center", marginBottom: 16 },
|
||||
retryButton: {
|
||||
backgroundColor: "#1e40af",
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
retryText: { color: "#fff", fontWeight: "600" },
|
||||
emptyState: { alignItems: "center", paddingTop: 60 },
|
||||
emptyEmoji: { fontSize: 48, marginBottom: 12 },
|
||||
emptyText: { fontSize: 16, color: "#374151", marginBottom: 8 },
|
||||
emptyHint: {
|
||||
fontSize: 13,
|
||||
color: "#6b7280",
|
||||
textAlign: "center",
|
||||
paddingHorizontal: 32,
|
||||
},
|
||||
});
|
||||
|
||||
const rowStyles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
marginBottom: 10,
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.04,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
icon: { fontSize: 22, marginRight: 12 },
|
||||
info: { flex: 1 },
|
||||
filename: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
color: "#111827",
|
||||
marginBottom: 4,
|
||||
},
|
||||
meta: { fontSize: 12, color: "#6b7280" },
|
||||
status: {
|
||||
fontSize: 11,
|
||||
color: "#6b7280",
|
||||
fontWeight: "500",
|
||||
textTransform: "capitalize",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* LoginScreen – entry point for unauthenticated users.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
|
||||
export default function LoginScreen() {
|
||||
const { signIn } = useAuth();
|
||||
const [serverUrl, setServerUrl] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSignIn() {
|
||||
const url = serverUrl.trim();
|
||||
if (!url) {
|
||||
Alert.alert("Server URL required", "Please enter the URL of your DocuElevate server.");
|
||||
return;
|
||||
}
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
Alert.alert("Invalid URL", "The server URL must start with http:// or https://");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await signIn(url);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Sign-in failed";
|
||||
Alert.alert("Sign-in failed", message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.logo}>DocuElevate</Text>
|
||||
<Text style={styles.tagline}>Intelligent Document Processing</Text>
|
||||
|
||||
<Text style={styles.label}>Server URL</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="https://your-docuelevate-server.com"
|
||||
placeholderTextColor="#9ca3af"
|
||||
value={serverUrl}
|
||||
onChangeText={setServerUrl}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
returnKeyType="go"
|
||||
onSubmitEditing={handleSignIn}
|
||||
accessibilityLabel="Server URL"
|
||||
/>
|
||||
|
||||
<Pressable
|
||||
style={[styles.button, loading && styles.buttonDisabled]}
|
||||
onPress={handleSignIn}
|
||||
disabled={loading}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Sign in with SSO"
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Sign in with SSO</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<Text style={styles.hint}>
|
||||
You will be redirected to your organisation's sign-in page.
|
||||
</Text>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#f3f4f6",
|
||||
justifyContent: "center",
|
||||
padding: 24,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: "#ffffff",
|
||||
borderRadius: 16,
|
||||
padding: 28,
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.08,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
},
|
||||
logo: {
|
||||
fontSize: 28,
|
||||
fontWeight: "700",
|
||||
color: "#1e40af",
|
||||
textAlign: "center",
|
||||
marginBottom: 4,
|
||||
},
|
||||
tagline: {
|
||||
fontSize: 14,
|
||||
color: "#6b7280",
|
||||
textAlign: "center",
|
||||
marginBottom: 32,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
color: "#374151",
|
||||
marginBottom: 6,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: "#d1d5db",
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
fontSize: 15,
|
||||
color: "#111827",
|
||||
marginBottom: 20,
|
||||
backgroundColor: "#f9fafb",
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#1e40af",
|
||||
borderRadius: 8,
|
||||
paddingVertical: 14,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: 48,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
buttonText: {
|
||||
color: "#ffffff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
hint: {
|
||||
marginTop: 16,
|
||||
fontSize: 12,
|
||||
color: "#9ca3af",
|
||||
textAlign: "center",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* ProfileScreen – authenticated user profile and settings.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Alert,
|
||||
Image,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const { user, signOut, baseUrl } = useAuth();
|
||||
|
||||
function handleSignOut() {
|
||||
Alert.alert("Sign out", "Are you sure you want to sign out?", [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Sign out",
|
||||
style: "destructive",
|
||||
onPress: signOut,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.emptyText}>Not signed in</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.scroll} contentContainerStyle={styles.content}>
|
||||
{/* Avatar + name */}
|
||||
<View style={styles.profileCard}>
|
||||
{user.avatar_url ? (
|
||||
<Image
|
||||
source={{ uri: user.avatar_url }}
|
||||
style={styles.avatar}
|
||||
accessibilityLabel={`Avatar for ${user.display_name ?? user.owner_id}`}
|
||||
/>
|
||||
) : (
|
||||
<View style={[styles.avatar, styles.avatarPlaceholder]}>
|
||||
<Text style={styles.avatarInitial}>
|
||||
{(user.display_name ?? user.owner_id).charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text style={styles.displayName}>{user.display_name ?? user.owner_id}</Text>
|
||||
{user.email && <Text style={styles.email}>{user.email}</Text>}
|
||||
{user.is_admin && <Text style={styles.adminBadge}>Admin</Text>}
|
||||
</View>
|
||||
|
||||
{/* Server info */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Connection</Text>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>Server</Text>
|
||||
<Text style={styles.rowValue} numberOfLines={1}>
|
||||
{baseUrl || "–"}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>User ID</Text>
|
||||
<Text style={styles.rowValue} numberOfLines={1}>
|
||||
{user.owner_id}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Danger zone */}
|
||||
<View style={styles.section}>
|
||||
<Pressable
|
||||
style={styles.signOutButton}
|
||||
onPress={handleSignOut}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Sign out"
|
||||
>
|
||||
<Text style={styles.signOutText}>Sign out</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: { flex: 1, backgroundColor: "#f9fafb" },
|
||||
content: { padding: 20 },
|
||||
center: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#f9fafb",
|
||||
},
|
||||
emptyText: { color: "#6b7280", fontSize: 16 },
|
||||
profileCard: {
|
||||
alignItems: "center",
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 16,
|
||||
padding: 24,
|
||||
marginBottom: 20,
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.06,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowRadius: 12,
|
||||
elevation: 3,
|
||||
},
|
||||
avatar: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
marginBottom: 14,
|
||||
},
|
||||
avatarPlaceholder: {
|
||||
backgroundColor: "#1e40af",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
avatarInitial: {
|
||||
color: "#fff",
|
||||
fontSize: 32,
|
||||
fontWeight: "700",
|
||||
},
|
||||
displayName: {
|
||||
fontSize: 20,
|
||||
fontWeight: "700",
|
||||
color: "#111827",
|
||||
marginBottom: 4,
|
||||
},
|
||||
email: { fontSize: 14, color: "#6b7280", marginBottom: 6 },
|
||||
adminBadge: {
|
||||
backgroundColor: "#dbeafe",
|
||||
color: "#1e40af",
|
||||
fontSize: 11,
|
||||
fontWeight: "700",
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
},
|
||||
section: {
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.04,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowRadius: 6,
|
||||
elevation: 2,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 13,
|
||||
fontWeight: "700",
|
||||
color: "#6b7280",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: 12,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#f3f4f6",
|
||||
},
|
||||
rowLabel: { fontSize: 14, color: "#374151" },
|
||||
rowValue: {
|
||||
fontSize: 14,
|
||||
color: "#6b7280",
|
||||
maxWidth: "60%",
|
||||
textAlign: "right",
|
||||
},
|
||||
signOutButton: {
|
||||
backgroundColor: "#fee2e2",
|
||||
borderRadius: 10,
|
||||
paddingVertical: 14,
|
||||
alignItems: "center",
|
||||
minHeight: 48,
|
||||
},
|
||||
signOutText: {
|
||||
color: "#dc2626",
|
||||
fontWeight: "700",
|
||||
fontSize: 15,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* UploadScreen – document upload via camera or file picker.
|
||||
*
|
||||
* Users can:
|
||||
* 1. Take a photo of a document with the device camera.
|
||||
* 2. Pick an existing file (PDF, image, Office document) from the Files app.
|
||||
* 3. Receive files shared from other apps via the iOS Share Sheet / Android
|
||||
* Share Intent (handled by the expo-sharing + deep-link integration).
|
||||
*/
|
||||
|
||||
import * as DocumentPicker from "expo-document-picker";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import api from "../services/api";
|
||||
|
||||
interface UploadItem {
|
||||
id: string;
|
||||
filename: string;
|
||||
status: "pending" | "uploading" | "done" | "error";
|
||||
error?: string;
|
||||
taskId?: string;
|
||||
}
|
||||
|
||||
export default function UploadScreen() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [uploads, setUploads] = useState<UploadItem[]>([]);
|
||||
|
||||
function updateItem(id: string, patch: Partial<UploadItem>) {
|
||||
setUploads((prev) =>
|
||||
prev.map((item) => (item.id === id ? { ...item, ...patch } : item))
|
||||
);
|
||||
}
|
||||
|
||||
async function uploadFile(uri: string, filename: string, mimeType?: string) {
|
||||
const id = `${Date.now()}-${filename}`;
|
||||
setUploads((prev) => [
|
||||
{ id, filename, status: "uploading" },
|
||||
...prev,
|
||||
]);
|
||||
|
||||
try {
|
||||
const resp = await api.uploadFile(uri, filename, mimeType);
|
||||
updateItem(id, { status: "done", taskId: resp.task_id });
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "Upload failed";
|
||||
updateItem(id, { status: "error", error: msg });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCamera() {
|
||||
const { status } = await ImagePicker.requestCameraPermissionsAsync();
|
||||
if (status !== "granted") {
|
||||
Alert.alert(
|
||||
"Camera access required",
|
||||
"Please grant camera access in Settings to capture documents."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchCameraAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
quality: 0.9,
|
||||
allowsEditing: false,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets.length > 0) {
|
||||
const asset = result.assets[0];
|
||||
const filename = `scan_${Date.now()}.jpg`;
|
||||
await uploadFile(asset.uri, filename, "image/jpeg");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFilePicker() {
|
||||
try {
|
||||
const result = await DocumentPicker.getDocumentAsync({
|
||||
type: "*/*",
|
||||
multiple: true,
|
||||
copyToCacheDirectory: true,
|
||||
});
|
||||
|
||||
if (!result.canceled) {
|
||||
for (const asset of result.assets) {
|
||||
await uploadFile(asset.uri, asset.name, asset.mimeType ?? undefined);
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
Alert.alert("File picker error", err instanceof Error ? err.message : "Could not open file picker");
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.emptyText}>Please sign in to upload documents.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Action buttons */}
|
||||
<View style={styles.actions}>
|
||||
<Pressable
|
||||
style={[styles.actionButton, styles.cameraButton]}
|
||||
onPress={handleCamera}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Capture document with camera"
|
||||
>
|
||||
<Text style={styles.actionIcon}>📷</Text>
|
||||
<Text style={styles.actionLabel}>Camera</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={[styles.actionButton, styles.fileButton]}
|
||||
onPress={handleFilePicker}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Pick file from device"
|
||||
>
|
||||
<Text style={styles.actionIcon}>📄</Text>
|
||||
<Text style={styles.actionLabel}>File Picker</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Upload list */}
|
||||
<ScrollView style={styles.list} contentContainerStyle={styles.listContent}>
|
||||
{uploads.length === 0 ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyEmoji}>☁️</Text>
|
||||
<Text style={styles.emptyText}>
|
||||
Tap Camera or File Picker to upload a document.
|
||||
</Text>
|
||||
<Text style={styles.emptyHint}>
|
||||
You can also share files from other apps directly to DocuElevate.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
uploads.map((item) => (
|
||||
<UploadRow key={item.id} item={item} />
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadRow({ item }: { item: UploadItem }) {
|
||||
const icons: Record<UploadItem["status"], string> = {
|
||||
pending: "⏳",
|
||||
uploading: "⬆️",
|
||||
done: "✅",
|
||||
error: "❌",
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={rowStyles.row}>
|
||||
<Text style={rowStyles.icon}>{icons[item.status]}</Text>
|
||||
<View style={rowStyles.info}>
|
||||
<Text style={rowStyles.filename} numberOfLines={1}>
|
||||
{item.filename}
|
||||
</Text>
|
||||
{item.status === "uploading" && (
|
||||
<ActivityIndicator size="small" color="#1e40af" />
|
||||
)}
|
||||
{item.status === "done" && (
|
||||
<Text style={rowStyles.statusDone}>Queued for processing</Text>
|
||||
)}
|
||||
{item.status === "error" && (
|
||||
<Text style={rowStyles.statusError}>{item.error}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: "#f9fafb" },
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
padding: 16,
|
||||
gap: 12,
|
||||
},
|
||||
actionButton: {
|
||||
flex: 1,
|
||||
borderRadius: 12,
|
||||
paddingVertical: 20,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: 80,
|
||||
},
|
||||
cameraButton: { backgroundColor: "#1e40af" },
|
||||
fileButton: { backgroundColor: "#059669" },
|
||||
actionIcon: { fontSize: 28, marginBottom: 6 },
|
||||
actionLabel: {
|
||||
color: "#fff",
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
},
|
||||
list: { flex: 1 },
|
||||
listContent: { padding: 16 },
|
||||
emptyState: {
|
||||
alignItems: "center",
|
||||
paddingTop: 60,
|
||||
},
|
||||
emptyEmoji: { fontSize: 48, marginBottom: 12 },
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
color: "#374151",
|
||||
textAlign: "center",
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptyHint: {
|
||||
fontSize: 13,
|
||||
color: "#6b7280",
|
||||
textAlign: "center",
|
||||
paddingHorizontal: 32,
|
||||
},
|
||||
center: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
});
|
||||
|
||||
const rowStyles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
marginBottom: 10,
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.04,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
icon: { fontSize: 22, marginRight: 12 },
|
||||
info: { flex: 1 },
|
||||
filename: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
color: "#111827",
|
||||
marginBottom: 4,
|
||||
},
|
||||
statusDone: { fontSize: 12, color: "#059669" },
|
||||
statusError: { fontSize: 12, color: "#dc2626" },
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* DocuElevate API client for the mobile app.
|
||||
*
|
||||
* All requests authenticate via a Bearer token stored in the device's secure
|
||||
* keychain (via expo-secure-store). The token is obtained once through the
|
||||
* SSO flow and cached until the user explicitly logs out.
|
||||
*/
|
||||
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const SECURE_STORE_API_TOKEN_KEY = "de_api_token";
|
||||
export const SECURE_STORE_BASE_URL_KEY = "de_base_url";
|
||||
export const SECURE_STORE_OWNER_ID_KEY = "de_owner_id";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface WhoAmIResponse {
|
||||
owner_id: string;
|
||||
display_name: string | null;
|
||||
email: string | null;
|
||||
avatar_url: string | null;
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
||||
export interface GenerateTokenResponse {
|
||||
token: string;
|
||||
token_id: number;
|
||||
name: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface DeviceRegistration {
|
||||
push_token: string;
|
||||
device_name?: string;
|
||||
platform: "ios" | "android" | "web";
|
||||
}
|
||||
|
||||
export interface FileRecord {
|
||||
id: number;
|
||||
filename: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
file_size: number | null;
|
||||
content_type: string | null;
|
||||
owner_id: string | null;
|
||||
}
|
||||
|
||||
export interface UploadResponse {
|
||||
task_id: string;
|
||||
status: string;
|
||||
message: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Base API client
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class DocuElevateAPI {
|
||||
private baseUrl: string = "";
|
||||
|
||||
async init(baseUrl: string): Promise<void> {
|
||||
this.baseUrl = baseUrl.replace(/\/$/, "");
|
||||
await SecureStore.setItemAsync(SECURE_STORE_BASE_URL_KEY, this.baseUrl);
|
||||
}
|
||||
|
||||
async loadFromStorage(): Promise<boolean> {
|
||||
try {
|
||||
const url = await SecureStore.getItemAsync(SECURE_STORE_BASE_URL_KEY);
|
||||
if (url) {
|
||||
this.baseUrl = url;
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
getBaseUrl(): string {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
private async getToken(): Promise<string | null> {
|
||||
try {
|
||||
return await SecureStore.getItemAsync(SECURE_STORE_API_TOKEN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
options?: { body?: unknown; formData?: FormData }
|
||||
): Promise<T> {
|
||||
const token = await this.getToken();
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
let body: BodyInit | undefined;
|
||||
if (options?.formData) {
|
||||
body = options.formData;
|
||||
// Let fetch set multipart content-type with boundary automatically
|
||||
} else if (options?.body !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
body = JSON.stringify(options.body);
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let detail = `HTTP ${response.status}`;
|
||||
try {
|
||||
const err = await response.json();
|
||||
detail = err.detail || JSON.stringify(err);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(detail);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as unknown as T;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Auth
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Exchange the current session (cookie) for a long-lived API token. */
|
||||
async generateMobileToken(deviceName: string): Promise<GenerateTokenResponse> {
|
||||
return this.request<GenerateTokenResponse>("POST", "/api/mobile/generate-token", {
|
||||
body: { device_name: deviceName },
|
||||
});
|
||||
}
|
||||
|
||||
/** Return profile information for the authenticated user. */
|
||||
async whoAmI(): Promise<WhoAmIResponse> {
|
||||
return this.request<WhoAmIResponse>("GET", "/api/mobile/whoami");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Push notifications
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Register a push notification device token. */
|
||||
async registerDevice(data: DeviceRegistration): Promise<void> {
|
||||
await this.request("POST", "/api/mobile/register-device", { body: data });
|
||||
}
|
||||
|
||||
/** Deactivate a device registration. */
|
||||
async deactivateDevice(deviceId: number): Promise<void> {
|
||||
await this.request("DELETE", `/api/mobile/devices/${deviceId}`);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Files
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Upload a file for processing. */
|
||||
async uploadFile(uri: string, filename: string, mimeType?: string): Promise<UploadResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", {
|
||||
uri,
|
||||
name: filename,
|
||||
type: mimeType || "application/octet-stream",
|
||||
} as unknown as Blob);
|
||||
|
||||
return this.request<UploadResponse>("POST", "/api/ui-upload", { formData });
|
||||
}
|
||||
|
||||
/** List recently processed files. */
|
||||
async listFiles(page = 1, pageSize = 20): Promise<FileRecord[]> {
|
||||
return this.request<FileRecord[]>(
|
||||
"GET",
|
||||
`/api/files?page=${page}&page_size=${pageSize}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new DocuElevateAPI();
|
||||
export default api;
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"lib": ["ESNext", "dom"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"jsx": "react-native",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
"""Tests for the mobile API endpoints (app/api/mobile.py)."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.database import Base, get_db
|
||||
from app.models import ApiToken, MobileDevice
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_OWNER = "mobile_user@example.com"
|
||||
_OTHER_OWNER = "other@example.com"
|
||||
_EXPO_TOKEN = "ExponentPushToken[test-token-abc123]"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mob_engine():
|
||||
"""In-memory SQLite engine for mobile tests."""
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
yield engine
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mob_session(mob_engine):
|
||||
"""DB session scoped to one test."""
|
||||
Session = sessionmaker(bind=mob_engine)
|
||||
session = Session()
|
||||
yield session
|
||||
session.close()
|
||||
|
||||
|
||||
def _make_client(mob_engine, owner_id: str = _OWNER) -> TestClient:
|
||||
"""Return a TestClient with *owner_id* injected as the authenticated user."""
|
||||
from app.api.mobile import _get_owner_id
|
||||
from app.main import app
|
||||
|
||||
Session = sessionmaker(bind=mob_engine)
|
||||
|
||||
def _override_get_db():
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def _override_owner():
|
||||
return owner_id
|
||||
|
||||
app.dependency_overrides[get_db] = _override_get_db
|
||||
app.dependency_overrides[_get_owner_id] = _override_owner
|
||||
|
||||
client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
|
||||
return client
|
||||
|
||||
|
||||
def _cleanup(app):
|
||||
"""Remove dependency overrides after test."""
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests – /mobile/generate-token
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGenerateMobileToken:
|
||||
"""Tests for POST /api/mobile/generate-token."""
|
||||
|
||||
def test_generate_token_success(self, mob_engine):
|
||||
"""Generating a mobile token returns a token string and metadata."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(mob_engine)
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/mobile/generate-token",
|
||||
json={"device_name": "John's iPhone"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["token"].startswith("de_")
|
||||
assert data["token_id"] > 0
|
||||
assert "Mobile App" in data["name"]
|
||||
assert "John's iPhone" in data["name"]
|
||||
assert "created_at" in data
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
def test_generate_token_default_device_name(self, mob_engine):
|
||||
"""A default device name is used if none is provided."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(mob_engine)
|
||||
try:
|
||||
resp = client.post("/api/mobile/generate-token", json={})
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert "Mobile App" in data["name"]
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
def test_generate_token_persisted_in_db(self, mob_engine, mob_session):
|
||||
"""The generated token is stored in the api_tokens table."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(mob_engine)
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/mobile/generate-token",
|
||||
json={"device_name": "Test Device"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
token_id = resp.json()["token_id"]
|
||||
|
||||
db_token = mob_session.get(ApiToken, token_id)
|
||||
assert db_token is not None
|
||||
assert db_token.owner_id == _OWNER
|
||||
assert "Mobile App" in db_token.name
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
def test_generate_token_unauthenticated(self, mob_engine):
|
||||
"""Unauthenticated requests are rejected with 401."""
|
||||
from app.api.mobile import _get_owner_id
|
||||
from app.main import app
|
||||
|
||||
Session = sessionmaker(bind=mob_engine)
|
||||
|
||||
def _override_get_db():
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def _raise_401():
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
|
||||
app.dependency_overrides[get_db] = _override_get_db
|
||||
app.dependency_overrides[_get_owner_id] = _raise_401
|
||||
|
||||
client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
|
||||
try:
|
||||
resp = client.post("/api/mobile/generate-token", json={"device_name": "Test"})
|
||||
assert resp.status_code == 401
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests – /mobile/register-device
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRegisterDevice:
|
||||
"""Tests for POST /api/mobile/register-device."""
|
||||
|
||||
def test_register_new_device(self, mob_engine, mob_session):
|
||||
"""Registering a new device persists it in mobile_devices."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(mob_engine)
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/mobile/register-device",
|
||||
json={
|
||||
"push_token": _EXPO_TOKEN,
|
||||
"device_name": "Test iPhone",
|
||||
"platform": "ios",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["id"] > 0
|
||||
assert data["platform"] == "ios"
|
||||
assert data["is_active"] is True
|
||||
assert "ExponentPushToken" in data["push_token_preview"]
|
||||
|
||||
device = mob_session.get(MobileDevice, data["id"])
|
||||
assert device is not None
|
||||
assert device.push_token == _EXPO_TOKEN
|
||||
assert device.owner_id == _OWNER
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
def test_register_same_token_is_idempotent(self, mob_engine, mob_session):
|
||||
"""Re-registering the same token reactivates the existing record."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(mob_engine)
|
||||
try:
|
||||
resp1 = client.post(
|
||||
"/api/mobile/register-device",
|
||||
json={"push_token": _EXPO_TOKEN, "platform": "ios"},
|
||||
)
|
||||
assert resp1.status_code == 201
|
||||
id1 = resp1.json()["id"]
|
||||
|
||||
resp2 = client.post(
|
||||
"/api/mobile/register-device",
|
||||
json={"push_token": _EXPO_TOKEN, "device_name": "Updated Name", "platform": "ios"},
|
||||
)
|
||||
assert resp2.status_code == 201
|
||||
id2 = resp2.json()["id"]
|
||||
|
||||
assert id1 == id2 # Same record reused
|
||||
|
||||
devices = mob_session.query(MobileDevice).filter(MobileDevice.owner_id == _OWNER).all()
|
||||
assert len(devices) == 1
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
def test_register_invalid_platform(self, mob_engine):
|
||||
"""An invalid platform value is rejected with 422."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(mob_engine)
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/mobile/register-device",
|
||||
json={"push_token": _EXPO_TOKEN, "platform": "windows"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
def test_register_android_device(self, mob_engine):
|
||||
"""Android devices can be registered."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(mob_engine)
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/mobile/register-device",
|
||||
json={
|
||||
"push_token": "ExponentPushToken[android-token-xyz]",
|
||||
"device_name": "Pixel 8",
|
||||
"platform": "android",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["platform"] == "android"
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests – /mobile/devices
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestListDevices:
|
||||
"""Tests for GET /api/mobile/devices."""
|
||||
|
||||
def test_list_devices_empty(self, mob_engine):
|
||||
"""An empty list is returned when no devices are registered."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(mob_engine)
|
||||
try:
|
||||
resp = client.get("/api/mobile/devices")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
def test_list_devices_returns_own_devices_only(self, mob_engine, mob_session):
|
||||
"""Only the current user's devices are returned."""
|
||||
from app.main import app
|
||||
|
||||
# Add devices for two different owners directly
|
||||
mob_session.add(
|
||||
MobileDevice(
|
||||
owner_id=_OWNER,
|
||||
push_token="ExponentPushToken[owner-token-12345]",
|
||||
platform="ios",
|
||||
)
|
||||
)
|
||||
mob_session.add(
|
||||
MobileDevice(
|
||||
owner_id=_OTHER_OWNER,
|
||||
push_token="ExponentPushToken[other-token-67890]",
|
||||
platform="android",
|
||||
)
|
||||
)
|
||||
mob_session.commit()
|
||||
|
||||
client = _make_client(mob_engine)
|
||||
try:
|
||||
resp = client.get("/api/mobile/devices")
|
||||
assert resp.status_code == 200
|
||||
devices = resp.json()
|
||||
assert len(devices) == 1
|
||||
# The push_token_preview is the first 20 chars + "…"
|
||||
assert devices[0]["push_token_preview"].startswith("ExponentPushToken[ow")
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests – DELETE /mobile/devices/{device_id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDeactivateDevice:
|
||||
"""Tests for DELETE /api/mobile/devices/{device_id}."""
|
||||
|
||||
def test_deactivate_own_device(self, mob_engine, mob_session):
|
||||
"""Deactivating a device sets is_active to False."""
|
||||
from app.main import app
|
||||
|
||||
device = MobileDevice(
|
||||
owner_id=_OWNER,
|
||||
push_token=_EXPO_TOKEN,
|
||||
platform="ios",
|
||||
is_active=True,
|
||||
)
|
||||
mob_session.add(device)
|
||||
mob_session.commit()
|
||||
mob_session.refresh(device)
|
||||
device_id = device.id
|
||||
|
||||
client = _make_client(mob_engine)
|
||||
try:
|
||||
resp = client.delete(f"/api/mobile/devices/{device_id}")
|
||||
assert resp.status_code == 204
|
||||
|
||||
mob_session.expire_all()
|
||||
updated = mob_session.get(MobileDevice, device_id)
|
||||
assert updated is not None
|
||||
assert updated.is_active is False
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
def test_deactivate_other_users_device_returns_404(self, mob_engine, mob_session):
|
||||
"""Attempting to deactivate another user's device returns 404."""
|
||||
from app.main import app
|
||||
|
||||
device = MobileDevice(
|
||||
owner_id=_OTHER_OWNER,
|
||||
push_token="ExponentPushToken[other-token]",
|
||||
platform="ios",
|
||||
is_active=True,
|
||||
)
|
||||
mob_session.add(device)
|
||||
mob_session.commit()
|
||||
mob_session.refresh(device)
|
||||
device_id = device.id
|
||||
|
||||
client = _make_client(mob_engine)
|
||||
try:
|
||||
resp = client.delete(f"/api/mobile/devices/{device_id}")
|
||||
assert resp.status_code == 404
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
def test_deactivate_nonexistent_device_returns_404(self, mob_engine):
|
||||
"""Deactivating a device that does not exist returns 404."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(mob_engine)
|
||||
try:
|
||||
resp = client.delete("/api/mobile/devices/99999")
|
||||
assert resp.status_code == 404
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests – /mobile/whoami
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestWhoAmI:
|
||||
"""Tests for GET /api/mobile/whoami."""
|
||||
|
||||
def test_whoami_with_no_profile(self, mob_engine):
|
||||
"""Returns owner_id and inferred email even when no UserProfile exists."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(mob_engine)
|
||||
try:
|
||||
resp = client.get("/api/mobile/whoami")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["owner_id"] == _OWNER
|
||||
assert data["display_name"] is None
|
||||
# _OWNER contains "@" so email is inferred from owner_id
|
||||
assert data["email"] == _OWNER
|
||||
assert data["avatar_url"] is not None # Gravatar URL from email
|
||||
assert data["is_admin"] is False
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
def test_whoami_with_profile(self, mob_engine, mob_session):
|
||||
"""Returns full profile data when a UserProfile record exists."""
|
||||
from app.main import app
|
||||
from app.models import UserProfile
|
||||
|
||||
profile = UserProfile(
|
||||
user_id=_OWNER,
|
||||
display_name="Alice Test",
|
||||
)
|
||||
mob_session.add(profile)
|
||||
mob_session.commit()
|
||||
|
||||
client = _make_client(mob_engine)
|
||||
try:
|
||||
resp = client.get("/api/mobile/whoami")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["owner_id"] == _OWNER
|
||||
assert data["display_name"] == "Alice Test"
|
||||
# owner_id contains "@" so email is inferred from it
|
||||
assert data["email"] == _OWNER
|
||||
assert data["avatar_url"] is not None # Gravatar URL
|
||||
assert data["is_admin"] is False
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests – push notification utility
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestPushNotificationUtility:
|
||||
"""Tests for app/utils/push_notification.py."""
|
||||
|
||||
def test_send_expo_push_empty_tokens(self):
|
||||
"""send_expo_push_notification with no tokens returns empty list."""
|
||||
from app.utils.push_notification import send_expo_push_notification
|
||||
|
||||
result = send_expo_push_notification([], "Title", "Body")
|
||||
assert result == []
|
||||
|
||||
def test_send_expo_push_calls_expo_api(self):
|
||||
"""send_expo_push_notification POSTs to the Expo push API."""
|
||||
from app.utils.push_notification import send_expo_push_notification
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"data": [{"status": "ok"}]}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch("app.utils.push_notification.httpx.post", return_value=mock_response) as mock_post:
|
||||
result = send_expo_push_notification(
|
||||
tokens=["ExponentPushToken[abc]"],
|
||||
title="Test",
|
||||
body="Message",
|
||||
)
|
||||
|
||||
mock_post.assert_called_once()
|
||||
call_kwargs = mock_post.call_args
|
||||
assert "exp.host" in call_kwargs[0][0]
|
||||
payload = call_kwargs[1]["json"]
|
||||
assert len(payload) == 1
|
||||
assert payload[0]["to"] == "ExponentPushToken[abc]"
|
||||
assert payload[0]["title"] == "Test"
|
||||
|
||||
def test_send_push_to_owner_no_devices(self, mob_engine):
|
||||
"""send_push_to_owner silently does nothing when no devices are registered."""
|
||||
from app.utils.push_notification import send_push_to_owner
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.query.return_value.filter.return_value.all.return_value = []
|
||||
mock_session.close = MagicMock()
|
||||
|
||||
with patch("app.utils.push_notification.SessionLocal", return_value=mock_session):
|
||||
with patch("app.utils.push_notification.send_expo_push_notification") as mock_send:
|
||||
send_push_to_owner("user@example.com", "Title", "Body")
|
||||
mock_send.assert_not_called()
|
||||
|
||||
def test_send_push_to_owner_with_devices(self):
|
||||
"""send_push_to_owner calls send_expo_push_notification with device tokens."""
|
||||
from app.utils.push_notification import send_push_to_owner
|
||||
|
||||
fake_device = MagicMock()
|
||||
fake_device.push_token = "ExponentPushToken[device1]"
|
||||
fake_device.is_active = True
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.query.return_value.filter.return_value.all.return_value = [fake_device]
|
||||
mock_session.close = MagicMock()
|
||||
|
||||
with patch("app.utils.push_notification.SessionLocal", return_value=mock_session):
|
||||
with patch("app.utils.push_notification.send_expo_push_notification") as mock_send:
|
||||
mock_send.return_value = [{"status": "ok"}]
|
||||
send_push_to_owner("user@example.com", "Processed!", "Your doc is ready.")
|
||||
|
||||
mock_send.assert_called_once()
|
||||
call_kwargs = mock_send.call_args[1]
|
||||
assert "ExponentPushToken[device1]" in call_kwargs["tokens"]
|
||||
Reference in New Issue
Block a user