From a50c3aadf5cc6ab7875a0fe3e36d1316e8b52d23 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 09:27:31 +0000 Subject: [PATCH 1/4] Initial plan From d538c0879d8d8ab3037e614450cd13e45419a2da Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 09:52:02 +0000 Subject: [PATCH 2/4] 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> --- .env.demo | 9 + app/api/__init__.py | 2 + app/api/mobile.py | 347 ++++++++++++ app/models.py | 34 ++ app/utils/push_notification.py | 130 +++++ app/utils/user_notification.py | 13 + docs/API.md | 69 +++ docs/MobileApp.md | 252 +++++++++ migrations/env.py | 1 + migrations/versions/027_add_mobile_devices.py | 41 ++ mobile/.gitignore | 21 + mobile/App.tsx | 126 +++++ mobile/README.md | 137 +++++ mobile/app.json | 70 +++ mobile/babel.config.js | 18 + mobile/eas.json | 33 ++ mobile/package.json | 63 +++ mobile/src/context/AuthContext.tsx | 186 +++++++ mobile/src/hooks/usePushNotifications.ts | 114 ++++ mobile/src/screens/FilesScreen.tsx | 220 ++++++++ mobile/src/screens/LoginScreen.tsx | 165 ++++++ mobile/src/screens/ProfileScreen.tsx | 195 +++++++ mobile/src/screens/UploadScreen.tsx | 258 +++++++++ mobile/src/services/api.ts | 199 +++++++ mobile/tsconfig.json | 19 + tests/test_api_mobile.py | 517 ++++++++++++++++++ 26 files changed, 3239 insertions(+) create mode 100644 app/api/mobile.py create mode 100644 app/utils/push_notification.py create mode 100644 docs/MobileApp.md create mode 100644 migrations/versions/027_add_mobile_devices.py create mode 100644 mobile/.gitignore create mode 100644 mobile/App.tsx create mode 100644 mobile/README.md create mode 100644 mobile/app.json create mode 100644 mobile/babel.config.js create mode 100644 mobile/eas.json create mode 100644 mobile/package.json create mode 100644 mobile/src/context/AuthContext.tsx create mode 100644 mobile/src/hooks/usePushNotifications.ts create mode 100644 mobile/src/screens/FilesScreen.tsx create mode 100644 mobile/src/screens/LoginScreen.tsx create mode 100644 mobile/src/screens/ProfileScreen.tsx create mode 100644 mobile/src/screens/UploadScreen.tsx create mode 100644 mobile/src/services/api.ts create mode 100644 mobile/tsconfig.json create mode 100644 tests/test_api_mobile.py diff --git a/.env.demo b/.env.demo index 65f82fdf..20486c8c 100644 --- a/.env.demo +++ b/.env.demo @@ -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. diff --git a/app/api/__init__.py b/app/api/__init__.py index ae98cbd7..a75e02a4 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.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) diff --git a/app/api/mobile.py b/app/api/mobile.py new file mode 100644 index 00000000..465872ab --- /dev/null +++ b/app/api/mobile.py @@ -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 – " 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, + } diff --git a/app/models.py b/app/models.py index 0cc7b53a..e9d2030b 100644 --- a/app/models.py +++ b/app/models.py @@ -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"),) diff --git a/app/utils/push_notification.py b/app/utils/push_notification.py new file mode 100644 index 00000000..f5f0e521 --- /dev/null +++ b/app/utils/push_notification.py @@ -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) diff --git a/app/utils/user_notification.py b/app/utils/user_notification.py index f6a277cd..791b2219 100644 --- a/app/utils/user_notification.py +++ b/app/utils/user_notification.py @@ -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.""" diff --git a/docs/API.md b/docs/API.md index d5a0bf40..2936d3cb 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 +} +``` diff --git a/docs/MobileApp.md b/docs/MobileApp.md new file mode 100644 index 00000000..92e92383 --- /dev/null +++ b/docs/MobileApp.md @@ -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 `/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 – "`) 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 " https://your-server/api/mobile/devices + +# Remove a device +curl -X DELETE -H "Authorization: Bearer " https://your-server/api/mobile/devices/ +``` + +## 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) diff --git a/migrations/env.py b/migrations/env.py index 903382a5..19785a0c 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -24,6 +24,7 @@ from app.models import ( # noqa: F401 DocumentMetadata, FileProcessingStep, FileRecord, + MobileDevice, ProcessingLog, SavedSearch, SettingsAuditLog, diff --git a/migrations/versions/027_add_mobile_devices.py b/migrations/versions/027_add_mobile_devices.py new file mode 100644 index 00000000..c9732be9 --- /dev/null +++ b/migrations/versions/027_add_mobile_devices.py @@ -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") diff --git a/mobile/.gitignore b/mobile/.gitignore new file mode 100644 index 00000000..ec7b118d --- /dev/null +++ b/mobile/.gitignore @@ -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 diff --git a/mobile/App.tsx b/mobile/App.tsx new file mode 100644 index 00000000..05d028b6 --- /dev/null +++ b/mobile/App.tsx @@ -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 ( + + ( + ⬆️ + ), + headerTitle: "DocuElevate", + }} + /> + ( + 📄 + ), + headerTitle: "My Documents", + }} + /> + ( + 👤 + ), + headerTitle: "Profile", + }} + /> + + ); +} + +function AppContent() { + const { isLoading, isAuthenticated } = useAuth(); + + if (isLoading) { + return ( + + + Loading… + + ); + } + + return ( + + {isAuthenticated ? : } + + ); +} + +export default function App() { + return ( + + + + + + ); +} + +const styles = StyleSheet.create({ + loading: { + flex: 1, + alignItems: "center", + justifyContent: "center", + backgroundColor: "#f9fafb", + gap: 12, + }, + loadingText: { + color: "#6b7280", + fontSize: 15, + }, +}); diff --git a/mobile/README.md b/mobile/README.md new file mode 100644 index 00000000..8d3c78df --- /dev/null +++ b/mobile/README.md @@ -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: + +## 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 ` on all requests. diff --git a/mobile/app.json b/mobile/app.json new file mode 100644 index 00000000..483d3e76 --- /dev/null +++ b/mobile/app.json @@ -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" + } + } +} diff --git a/mobile/babel.config.js b/mobile/babel.config.js new file mode 100644 index 00000000..61393521 --- /dev/null +++ b/mobile/babel.config.js @@ -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", + ], + }; +}; diff --git a/mobile/eas.json b/mobile/eas.json new file mode 100644 index 00000000..7917081e --- /dev/null +++ b/mobile/eas.json @@ -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" + } + } + } +} diff --git a/mobile/package.json b/mobile/package.json new file mode 100644 index 00000000..6223e0bb --- /dev/null +++ b/mobile/package.json @@ -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"] + } + } + } +} diff --git a/mobile/src/context/AuthContext.tsx b/mobile/src/context/AuthContext.tsx new file mode 100644 index 00000000..94deab40 --- /dev/null +++ b/mobile/src/context/AuthContext.tsx @@ -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; + signOut: () => Promise; + setToken: (token: string) => Promise; +} + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +const AuthContext = createContext({ + 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(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 ( + + {children} + + ); +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +export function useAuth(): AuthState { + return useContext(AuthContext); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function _getDeviceName(): Promise { + try { + const Constants = await import("expo-constants"); + return ( + Constants.default.deviceName || + Constants.default.expoConfig?.name || + "Mobile App" + ); + } catch { + return "Mobile App"; + } +} diff --git a/mobile/src/hooks/usePushNotifications.ts b/mobile/src/hooks/usePushNotifications.ts new file mode 100644 index 00000000..85c3c97c --- /dev/null +++ b/mobile/src/hooks/usePushNotifications.ts @@ -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(null); + const responseListener = useRef(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; + // 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]); +} diff --git a/mobile/src/screens/FilesScreen.tsx b/mobile/src/screens/FilesScreen.tsx new file mode 100644 index 00000000..2cec7c13 --- /dev/null +++ b/mobile/src/screens/FilesScreen.tsx @@ -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 = { + processed: "✅", + processing: "⚙️", + queued: "⏳", + failed: "❌", + uploaded: "⬆️", + }; + return map[status?.toLowerCase()] ?? "📄"; +} + +export default function FilesScreen() { + const [files, setFiles] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(true); + const [error, setError] = useState(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 ( + + + + ); + } + + if (error) { + return ( + + {error} + + Retry + + + ); + } + + return ( + String(item.id)} + contentContainerStyle={styles.listContent} + renderItem={({ item }) => } + refreshControl={ + + } + onEndReached={handleLoadMore} + onEndReachedThreshold={0.4} + ListEmptyComponent={ + + 📂 + No documents yet. + + Upload a document from the Upload tab to get started. + + + } + ListFooterComponent={ + hasMore && files.length > 0 ? ( + + ) : null + } + /> + ); +} + +function FileRow({ file }: { file: FileRecord }) { + return ( + + {statusEmoji(file.status)} + + + {file.filename} + + + {formatDate(file.created_at)} · {formatBytes(file.file_size)} + + + {file.status} + + ); +} + +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", + }, +}); diff --git a/mobile/src/screens/LoginScreen.tsx b/mobile/src/screens/LoginScreen.tsx new file mode 100644 index 00000000..b5fa5cb6 --- /dev/null +++ b/mobile/src/screens/LoginScreen.tsx @@ -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 ( + + + DocuElevate + Intelligent Document Processing + + Server URL + + + + {loading ? ( + + ) : ( + Sign in with SSO + )} + + + + You will be redirected to your organisation's sign-in page. + + + + ); +} + +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", + }, +}); diff --git a/mobile/src/screens/ProfileScreen.tsx b/mobile/src/screens/ProfileScreen.tsx new file mode 100644 index 00000000..ecc39d72 --- /dev/null +++ b/mobile/src/screens/ProfileScreen.tsx @@ -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 ( + + Not signed in + + ); + } + + return ( + + {/* Avatar + name */} + + {user.avatar_url ? ( + + ) : ( + + + {(user.display_name ?? user.owner_id).charAt(0).toUpperCase()} + + + )} + {user.display_name ?? user.owner_id} + {user.email && {user.email}} + {user.is_admin && Admin} + + + {/* Server info */} + + Connection + + Server + + {baseUrl || "–"} + + + + User ID + + {user.owner_id} + + + + + {/* Danger zone */} + + + Sign out + + + + ); +} + +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, + }, +}); diff --git a/mobile/src/screens/UploadScreen.tsx b/mobile/src/screens/UploadScreen.tsx new file mode 100644 index 00000000..b9685ee9 --- /dev/null +++ b/mobile/src/screens/UploadScreen.tsx @@ -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([]); + + function updateItem(id: string, patch: Partial) { + 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 ( + + Please sign in to upload documents. + + ); + } + + return ( + + {/* Action buttons */} + + + 📷 + Camera + + + + 📄 + File Picker + + + + {/* Upload list */} + + {uploads.length === 0 ? ( + + ☁️ + + Tap Camera or File Picker to upload a document. + + + You can also share files from other apps directly to DocuElevate. + + + ) : ( + uploads.map((item) => ( + + )) + )} + + + ); +} + +function UploadRow({ item }: { item: UploadItem }) { + const icons: Record = { + pending: "⏳", + uploading: "⬆️", + done: "✅", + error: "❌", + }; + + return ( + + {icons[item.status]} + + + {item.filename} + + {item.status === "uploading" && ( + + )} + {item.status === "done" && ( + Queued for processing + )} + {item.status === "error" && ( + {item.error} + )} + + + ); +} + +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" }, +}); diff --git a/mobile/src/services/api.ts b/mobile/src/services/api.ts new file mode 100644 index 00000000..7a523216 --- /dev/null +++ b/mobile/src/services/api.ts @@ -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 { + this.baseUrl = baseUrl.replace(/\/$/, ""); + await SecureStore.setItemAsync(SECURE_STORE_BASE_URL_KEY, this.baseUrl); + } + + async loadFromStorage(): Promise { + 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 { + try { + return await SecureStore.getItemAsync(SECURE_STORE_API_TOKEN_KEY); + } catch { + return null; + } + } + + private async request( + method: string, + path: string, + options?: { body?: unknown; formData?: FormData } + ): Promise { + const token = await this.getToken(); + const headers: Record = {}; + + 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 { + return this.request("POST", "/api/mobile/generate-token", { + body: { device_name: deviceName }, + }); + } + + /** Return profile information for the authenticated user. */ + async whoAmI(): Promise { + return this.request("GET", "/api/mobile/whoami"); + } + + // ------------------------------------------------------------------------- + // Push notifications + // ------------------------------------------------------------------------- + + /** Register a push notification device token. */ + async registerDevice(data: DeviceRegistration): Promise { + await this.request("POST", "/api/mobile/register-device", { body: data }); + } + + /** Deactivate a device registration. */ + async deactivateDevice(deviceId: number): Promise { + await this.request("DELETE", `/api/mobile/devices/${deviceId}`); + } + + // ------------------------------------------------------------------------- + // Files + // ------------------------------------------------------------------------- + + /** Upload a file for processing. */ + async uploadFile(uri: string, filename: string, mimeType?: string): Promise { + const formData = new FormData(); + formData.append("file", { + uri, + name: filename, + type: mimeType || "application/octet-stream", + } as unknown as Blob); + + return this.request("POST", "/api/ui-upload", { formData }); + } + + /** List recently processed files. */ + async listFiles(page = 1, pageSize = 20): Promise { + return this.request( + "GET", + `/api/files?page=${page}&page_size=${pageSize}` + ); + } +} + +export const api = new DocuElevateAPI(); +export default api; diff --git a/mobile/tsconfig.json b/mobile/tsconfig.json new file mode 100644 index 00000000..58432076 --- /dev/null +++ b/mobile/tsconfig.json @@ -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"] +} diff --git a/tests/test_api_mobile.py b/tests/test_api_mobile.py new file mode 100644 index 00000000..f60c3b28 --- /dev/null +++ b/tests/test_api_mobile.py @@ -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"] From 4eb04bd7f64a916d3b30ba6c232e3dd8560d846c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 09:57:58 +0000 Subject: [PATCH 3/4] fix(mobile): address code review findings in mobile app config and tests Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- mobile/README.md | 11 ++++++++--- mobile/babel.config.js | 12 ------------ mobile/tsconfig.json | 5 ++--- tests/test_api_mobile.py | 1 + 4 files changed, 11 insertions(+), 18 deletions(-) diff --git a/mobile/README.md b/mobile/README.md index 8d3c78df..45bf2e9a 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -43,7 +43,7 @@ npm install -g eas-cli # Log in to Expo eas login -# Configure your project (one-time) +# Configure your project (one-time) – updates app.json with the EAS project ID eas init # Build for iOS @@ -56,16 +56,21 @@ eas build --platform android eas build --platform all ``` +> **After running `eas init`:** update the `extra.eas.projectId` field in `app.json` with the value printed by the command. + ### iOS-specific - An Apple Developer account is required for TestFlight and App Store distribution -- Update `eas.json` with your `appleId`, `ascAppId`, and `appleTeamId` +- Update `eas.json` → `submit.production.ios` with: + - `appleId`: your Apple ID email address + - `ascAppId`: App Store Connect → App Information → Apple ID + - `appleTeamId`: Apple Developer portal → Membership → Team ID - 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 +- For Play Store submission: create a service account in Google Play Console, download the JSON key as `google-play-service-account.json`, and update `eas.json` ## Configuration diff --git a/mobile/babel.config.js b/mobile/babel.config.js index 61393521..73ebf58e 100644 --- a/mobile/babel.config.js +++ b/mobile/babel.config.js @@ -2,17 +2,5 @@ module.exports = function (api) { api.cache(true); return { presets: ["babel-preset-expo"], - plugins: [ - [ - "module-resolver", - { - root: ["./"], - alias: { - "@": "./src", - }, - }, - ], - "react-native-reanimated/plugin", - ], }; }; diff --git a/mobile/tsconfig.json b/mobile/tsconfig.json index 58432076..20e4176b 100644 --- a/mobile/tsconfig.json +++ b/mobile/tsconfig.json @@ -1,10 +1,9 @@ { "compilerOptions": { "target": "ESNext", - "lib": ["ESNext", "dom"], + "lib": ["ESNext"], "module": "ESNext", - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, + "moduleResolution": "node", "strict": true, "esModuleInterop": true, "skipLibCheck": true, diff --git a/tests/test_api_mobile.py b/tests/test_api_mobile.py index f60c3b28..54b08967 100644 --- a/tests/test_api_mobile.py +++ b/tests/test_api_mobile.py @@ -494,6 +494,7 @@ class TestPushNotificationUtility: 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() + mock_session.close.assert_called_once() def test_send_push_to_owner_with_devices(self): """send_push_to_owner calls send_expo_push_notification with device tokens.""" From 1d5eee4281428c2887f499aa7ac30d7a3555f727 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:43:29 +0000 Subject: [PATCH 4/4] fix(migrations): re-chain mobile devices migration against main and restore merge conflicts Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- app/api/__init__.py | 4 + app/api/audit_logs.py | 115 ++++ app/api/i18n.py | 136 +++++ app/config.py | 43 ++ app/models.py | 25 + app/utils/audit_service.py | 331 +++++++++++ app/utils/db_migrate.py | 2 + app/utils/i18n.py | 539 ++++++++++++++++++ app/utils/settings_service.py | 72 +++ app/views/__init__.py | 2 + app/views/audit_logs.py | 46 ++ app/views/base.py | 41 ++ migrations/env.py | 16 + .../versions/027_ensure_shared_links_table.py | 62 ++ migrations/versions/028_add_audit_logs.py | 47 ++ .../029_add_user_language_preference.py | 28 + ...e_devices.py => 030_add_mobile_devices.py} | 8 +- 17 files changed, 1513 insertions(+), 4 deletions(-) create mode 100644 app/api/audit_logs.py create mode 100644 app/api/i18n.py create mode 100644 app/utils/audit_service.py create mode 100644 app/utils/i18n.py create mode 100644 app/views/audit_logs.py create mode 100644 migrations/versions/027_ensure_shared_links_table.py create mode 100644 migrations/versions/028_add_audit_logs.py create mode 100644 migrations/versions/029_add_user_language_preference.py rename migrations/versions/{027_add_mobile_devices.py => 030_add_mobile_devices.py} (88%) diff --git a/app/api/__init__.py b/app/api/__init__.py index a75e02a4..87fd418d 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -8,6 +8,7 @@ from fastapi import APIRouter from app.api.admin_users import router as admin_users_router from app.api.api_tokens import router as api_tokens_router +from app.api.audit_logs import router as audit_logs_router from app.api.azure import router as azure_router from app.api.backup import router as backup_router from app.api.billing import router as billing_router @@ -17,6 +18,7 @@ from app.api.dropbox import router as dropbox_router from app.api.duplicates import router as duplicates_router from app.api.files import router as files_router from app.api.google_drive import router as google_drive_router +from app.api.i18n import router as i18n_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 @@ -83,4 +85,6 @@ 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(audit_logs_router) +router.include_router(i18n_router) router.include_router(mobile_router) diff --git a/app/api/audit_logs.py b/app/api/audit_logs.py new file mode 100644 index 00000000..a4ed9d10 --- /dev/null +++ b/app/api/audit_logs.py @@ -0,0 +1,115 @@ +""" +Audit log REST API endpoints. + +Provides read-only access to the comprehensive audit log for admin users. +Events are append-only — there are no update or delete endpoints. +""" + +import logging +from datetime import datetime +from typing import Any + +from fastapi import APIRouter, Depends, Query, Request +from sqlalchemy.orm import Session + +from app.auth import require_login +from app.database import get_db +from app.utils.audit_service import count_events, query_events + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.get("/audit-logs") +@require_login +async def list_audit_logs( + request: Request, + db: Session = Depends(get_db), + action: str | None = Query(None, description="Filter by action (exact match)"), + user: str | None = Query(None, description="Filter by username"), + resource_type: str | None = Query(None, description="Filter by resource type"), + severity: str | None = Query(None, description="Filter by severity level"), + since: datetime | None = Query(None, description="Only events at or after this ISO-8601 timestamp"), + until: datetime | None = Query(None, description="Only events at or before this ISO-8601 timestamp"), + limit: int = Query(50, ge=1, le=500, description="Max rows to return"), + offset: int = Query(0, ge=0, description="Rows to skip for pagination"), +) -> dict[str, Any]: + """Return audit log entries with optional filtering and pagination. + + Requires authentication. Returns events in reverse chronological order. + """ + entries = query_events( + db, + action=action, + user=user, + resource_type=resource_type, + severity=severity, + since=since, + until=until, + limit=limit, + offset=offset, + ) + total = count_events( + db, + action=action, + user=user, + resource_type=resource_type, + severity=severity, + since=since, + until=until, + ) + return { + "items": [_serialize(e) for e in entries], + "total": total, + "limit": limit, + "offset": offset, + } + + +@router.get("/audit-logs/actions") +@require_login +async def list_distinct_actions( + request: Request, + db: Session = Depends(get_db), +) -> list[str]: + """Return the distinct action values present in the audit log.""" + from app.models import AuditLog + + rows = db.query(AuditLog.action).distinct().order_by(AuditLog.action).all() + return [r[0] for r in rows] + + +@router.get("/audit-logs/users") +@require_login +async def list_distinct_users( + request: Request, + db: Session = Depends(get_db), +) -> list[str]: + """Return the distinct user values present in the audit log.""" + from app.models import AuditLog + + rows = db.query(AuditLog.user).distinct().order_by(AuditLog.user).all() + return [r[0] for r in rows] + + +# ------------------------------------------------------------------ +# Helpers +# ------------------------------------------------------------------ + + +def _serialize(entry) -> dict[str, Any]: + """Convert an AuditLog row to a JSON-safe dict.""" + import json as _json + + return { + "id": entry.id, + "timestamp": entry.timestamp.isoformat() if entry.timestamp else None, + "user": entry.user, + "action": entry.action, + "resource_type": entry.resource_type, + "resource_id": entry.resource_id, + "ip_address": entry.ip_address, + "details": _json.loads(entry.details) if entry.details else None, + "severity": entry.severity, + } diff --git a/app/api/i18n.py b/app/api/i18n.py new file mode 100644 index 00000000..d8f5bbb3 --- /dev/null +++ b/app/api/i18n.py @@ -0,0 +1,136 @@ +"""API endpoints for internationalization (i18n). + +Provides endpoints for: +* Listing available languages +* Getting/setting user language preference (persisted in session + cookie + DB) +""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends, Request, Response +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models import UserProfile +from app.utils.i18n import ( + DEFAULT_LANGUAGE, + SUPPORTED_LANGUAGE_CODES, + SUPPORTED_LANGUAGES, + detect_language, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/i18n", tags=["i18n"]) + + +class LanguageInfo(BaseModel): + """Schema for a supported language.""" + + code: str + name: str + native: str + flag: str + + +class LanguageListResponse(BaseModel): + """Response for the list-languages endpoint.""" + + languages: list[LanguageInfo] + current: str + default: str + + +class SetLanguageRequest(BaseModel): + """Request body for setting the preferred language.""" + + language: str + + +class SetLanguageResponse(BaseModel): + """Response after changing the language.""" + + language: str + message: str + + +@router.get("/languages", response_model=LanguageListResponse) +async def list_languages(request: Request) -> LanguageListResponse: + """Return all supported UI languages and the current active language.""" + current = detect_language(request) + return LanguageListResponse( + languages=[LanguageInfo(**lang) for lang in SUPPORTED_LANGUAGES], + current=current, + default=DEFAULT_LANGUAGE, + ) + + +@router.post("/language", response_model=SetLanguageResponse) +async def set_language( + body: SetLanguageRequest, + request: Request, + response: Response, + db: Session = Depends(get_db), +) -> SetLanguageResponse: + """Set the preferred UI language. + + Persists the choice in: + 1. The server-side session + 2. A ``docuelevate_lang`` cookie (30-day expiry) + 3. The ``UserProfile.preferred_language`` column (if authenticated) + """ + lang = body.language.lower().strip() + if lang not in SUPPORTED_LANGUAGE_CODES: + lang = DEFAULT_LANGUAGE + + # 1. Session + if hasattr(request, "session"): + request.session["preferred_language"] = lang + + # 2. Cookie (30 days) + response.set_cookie( + key="docuelevate_lang", + value=lang, + max_age=30 * 24 * 60 * 60, + httponly=False, + samesite="lax", + ) + + # 3. Database (if user is authenticated) + _persist_language_to_profile(request, db, lang) + + language_name = next( + (entry["native"] for entry in SUPPORTED_LANGUAGES if entry["code"] == lang), + lang, + ) + logger.info("Language preference set to '%s'", lang) + return SetLanguageResponse( + language=lang, + message=f"Language changed to {language_name}", + ) + + +def _persist_language_to_profile(request: Request, db: Session, lang: str) -> None: + """Write language preference to the UserProfile row, if the user is logged in.""" + user_id: str | None = None + if hasattr(request, "session"): + user = request.session.get("user") + if isinstance(user, dict): + user_id = user.get("preferred_username") or user.get("email") or user.get("id") + elif isinstance(user, str): + user_id = user + + if not user_id: + return + + try: + profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first() + if profile: + profile.preferred_language = lang # type: ignore[attr-defined] + db.commit() + except Exception: + db.rollback() + logger.debug("Could not persist language preference for user_id=%s", user_id) diff --git a/app/config.py b/app/config.py index ba6eeb25..51df35e0 100644 --- a/app/config.py +++ b/app/config.py @@ -849,6 +849,49 @@ class Settings(BaseSettings): ), ) + # SIEM / External Audit Log Forwarding + # Forward audit events to external SIEM systems for centralised monitoring. + audit_siem_enabled: bool = Field( + default=False, + description="Enable forwarding of audit events to an external SIEM system.", + ) + audit_siem_transport: str = Field( + default="syslog", + description=( + "Transport used to forward audit events. " + "Options: 'syslog' (RFC 5424 over UDP/TCP), 'http' (JSON POST to a webhook URL, " + "compatible with Splunk HEC, Logstash HTTP input, Grafana Loki, etc.)." + ), + ) + audit_siem_syslog_host: str = Field( + default="localhost", + description="Hostname or IP of the syslog receiver.", + ) + audit_siem_syslog_port: int = Field( + default=514, + description="Port of the syslog receiver.", + ) + audit_siem_syslog_protocol: str = Field( + default="udp", + description="Protocol for syslog transport: 'udp' or 'tcp'.", + ) + audit_siem_http_url: str = Field( + default="", + description=( + "HTTP endpoint URL for SIEM webhook delivery. " + "Supports Splunk HEC (https://splunk:8088/services/collector/event), " + "Logstash HTTP input, Grafana Loki push API, or any JSON-accepting endpoint." + ), + ) + audit_siem_http_token: str = Field( + default="", + description="Bearer / HEC token included in the Authorization header of SIEM HTTP requests.", + ) + audit_siem_http_custom_headers: str = Field( + default="", + description="Comma-separated 'Key:Value' pairs of extra headers for SIEM HTTP requests.", + ) + # UI / Appearance ui_default_color_scheme: str = Field( default="system", diff --git a/app/models.py b/app/models.py index e9d2030b..0bbb3b45 100644 --- a/app/models.py +++ b/app/models.py @@ -146,6 +146,27 @@ class SettingsAuditLog(Base): action = Column(String, nullable=False) # "update" or "delete" +class AuditLog(Base): + """Comprehensive audit log for compliance tracking. + + Records all significant actions: login/logout, document CRUD, settings + changes, and administrative operations. Rows are append-only; the API + and service layer never update or delete entries. + """ + + __tablename__ = "audit_logs" + + id = Column(Integer, primary_key=True, index=True) + timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True) + user = Column(String, nullable=False, index=True) # Username or "anonymous" / "system" + action = Column(String, nullable=False, index=True) # e.g. "login", "document.create", "settings.update" + resource_type = Column(String, nullable=True, index=True) # e.g. "document", "user", "settings" + resource_id = Column(String, nullable=True) # ID of the affected resource + ip_address = Column(String, nullable=True) # Client IP address + details = Column(Text, nullable=True) # JSON-encoded extra context + severity = Column(String(16), nullable=False, server_default="info") # info / warning / error / critical + + class SavedSearch(Base): """User-defined saved search filters for quick access to frequently used filter combinations.""" @@ -256,6 +277,10 @@ class UserProfile(Base): preferred_destination = Column(String(50), nullable=True) stripe_customer_id = Column(String(64), nullable=True) + # UI language preference for i18n (ISO 639-1 code, e.g. "en", "de", "fr") + # NULL means "auto-detect from browser Accept-Language header" + preferred_language = Column(String(10), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/app/utils/audit_service.py b/app/utils/audit_service.py new file mode 100644 index 00000000..73a6af7c --- /dev/null +++ b/app/utils/audit_service.py @@ -0,0 +1,331 @@ +""" +Comprehensive audit-event service for DocuElevate. + +Provides helpers to **record** audit events (append-only database writes) +and to optionally **forward** them to external SIEM systems. + +Supported SIEM transports: +* **Syslog** – RFC 5424 structured-data messages over UDP or TCP. +* **HTTP** – JSON POST payloads compatible with Splunk HEC, Logstash + HTTP input, Grafana Loki push API, and any generic webhook endpoint. +""" + +import json +import logging +import re +import socket +import threading +from datetime import datetime, timezone +from typing import Any + +import httpx +from fastapi import Request +from sqlalchemy.orm import Session + +from app.config import settings +from app.middleware.audit_log import get_client_ip, get_username +from app.models import AuditLog + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Public helpers +# --------------------------------------------------------------------------- + + +def record_event( + db: Session, + *, + action: str, + user: str = "system", + resource_type: str | None = None, + resource_id: str | None = None, + ip_address: str | None = None, + details: dict[str, Any] | None = None, + severity: str = "info", +) -> AuditLog: + """Persist an audit event and optionally forward it to SIEM. + + Args: + db: Active SQLAlchemy session. + action: Short action identifier (e.g. ``"login"``, ``"document.create"``). + user: Username performing the action. + resource_type: Category of the affected resource (``"document"``, ``"user"`` …). + resource_id: Identifier of the affected resource. + ip_address: Client IP address (``None`` when not applicable). + details: Arbitrary key/value context serialised as JSON. + severity: One of ``info``, ``warning``, ``error``, ``critical``. + + Returns: + The newly created :class:`AuditLog` row. + """ + details_json = json.dumps(details, default=str) if details else None + + entry = AuditLog( + user=user, + action=action, + resource_type=resource_type, + resource_id=str(resource_id) if resource_id is not None else None, + ip_address=ip_address, + details=details_json, + severity=severity, + ) + db.add(entry) + db.commit() + db.refresh(entry) + + # Fire-and-forget SIEM forwarding in a background thread so we never + # block the request path. + if settings.audit_siem_enabled: + payload = _build_siem_payload(entry) + thread = threading.Thread(target=_forward_to_siem, args=(payload,), daemon=True) + thread.start() + + return entry + + +def record_event_from_request( + db: Session, + request: Request, + *, + action: str, + resource_type: str | None = None, + resource_id: str | None = None, + details: dict[str, Any] | None = None, + severity: str = "info", +) -> AuditLog: + """Convenience wrapper that extracts user and IP from a :class:`Request`. + + Args: + db: Active SQLAlchemy session. + request: The current HTTP request. + action: Short action identifier. + resource_type: Category of the affected resource. + resource_id: Identifier of the affected resource. + details: Arbitrary key/value context serialised as JSON. + severity: One of ``info``, ``warning``, ``error``, ``critical``. + + Returns: + The newly created :class:`AuditLog` row. + """ + return record_event( + db, + action=action, + user=get_username(request), + resource_type=resource_type, + resource_id=resource_id, + ip_address=get_client_ip(request), + details=details, + severity=severity, + ) + + +def query_events( + db: Session, + *, + action: str | None = None, + user: str | None = None, + resource_type: str | None = None, + severity: str | None = None, + since: datetime | None = None, + until: datetime | None = None, + limit: int = 200, + offset: int = 0, +) -> list[AuditLog]: + """Query audit log entries with optional filtering. + + Args: + db: Active SQLAlchemy session. + action: Filter by action string (exact match). + user: Filter by username (exact match). + resource_type: Filter by resource type (exact match). + severity: Filter by severity level (exact match). + since: Only events at or after this timestamp. + until: Only events at or before this timestamp. + limit: Maximum number of rows to return. + offset: Number of rows to skip (for pagination). + + Returns: + List of :class:`AuditLog` rows ordered by *timestamp descending*. + """ + q = db.query(AuditLog) + if action: + q = q.filter(AuditLog.action == action) + if user: + q = q.filter(AuditLog.user == user) + if resource_type: + q = q.filter(AuditLog.resource_type == resource_type) + if severity: + q = q.filter(AuditLog.severity == severity) + if since: + q = q.filter(AuditLog.timestamp >= since) + if until: + q = q.filter(AuditLog.timestamp <= until) + return q.order_by(AuditLog.timestamp.desc()).offset(offset).limit(limit).all() + + +def count_events( + db: Session, + *, + action: str | None = None, + user: str | None = None, + resource_type: str | None = None, + severity: str | None = None, + since: datetime | None = None, + until: datetime | None = None, +) -> int: + """Return the total count of events matching the given filters. + + Args: + db: Active SQLAlchemy session. + action: Filter by action string. + user: Filter by username. + resource_type: Filter by resource type. + severity: Filter by severity level. + since: Only events at or after this timestamp. + until: Only events at or before this timestamp. + + Returns: + Integer count. + """ + q = db.query(AuditLog) + if action: + q = q.filter(AuditLog.action == action) + if user: + q = q.filter(AuditLog.user == user) + if resource_type: + q = q.filter(AuditLog.resource_type == resource_type) + if severity: + q = q.filter(AuditLog.severity == severity) + if since: + q = q.filter(AuditLog.timestamp >= since) + if until: + q = q.filter(AuditLog.timestamp <= until) + return q.count() + + +# --------------------------------------------------------------------------- +# SIEM forwarding internals +# --------------------------------------------------------------------------- + +_SYSLOG_FACILITY_LOCAL0 = 16 +_SYSLOG_SEVERITY_MAP = { + "info": 6, + "warning": 4, + "error": 3, + "critical": 2, +} + + +def _build_siem_payload(entry: AuditLog) -> dict[str, Any]: + """Convert an :class:`AuditLog` row into a plain dict for SIEM delivery.""" + ts = entry.timestamp if entry.timestamp else datetime.now(timezone.utc) + return { + "id": entry.id, + "timestamp": ts.isoformat(), + "user": entry.user, + "action": entry.action, + "resource_type": entry.resource_type, + "resource_id": entry.resource_id, + "ip_address": entry.ip_address, + "details": entry.details, + "severity": entry.severity, + "source": "docuelevate", + } + + +def _forward_to_siem(payload: dict[str, Any]) -> None: + """Route a SIEM payload to the configured transport.""" + transport = settings.audit_siem_transport.lower() + try: + if transport == "syslog": + _send_syslog(payload) + elif transport == "http": + _send_http(payload) + else: + logger.warning("Unknown SIEM transport %r; skipping forwarding", transport) + except Exception: + logger.exception("Failed to forward audit event to SIEM (%s)", transport) + + +def _send_syslog(payload: dict[str, Any]) -> None: + """Send a RFC 5424 syslog message to the configured receiver.""" + severity_num = _SYSLOG_SEVERITY_MAP.get(payload.get("severity", "info"), 6) + priority = _SYSLOG_FACILITY_LOCAL0 * 8 + severity_num + ts = payload.get("timestamp", datetime.now(timezone.utc).isoformat()) + hostname = socket.gethostname() + app_name = "docuelevate" + msg_id = payload.get("action", "-") + + # Structured data (SD) element with key event fields. + sd = ( + f'[docuelevate@0 user="{payload.get("user", "-")}" ' + f'action="{payload.get("action", "-")}" ' + f'resource_type="{payload.get("resource_type", "-")}" ' + f'resource_id="{payload.get("resource_id", "-")}" ' + f'ip="{payload.get("ip_address", "-")}"]' + ) + message = json.dumps(payload, default=str) + syslog_msg = f"<{priority}>1 {ts} {hostname} {app_name} - {msg_id} {sd} {message}" + + proto = settings.audit_siem_syslog_protocol.lower() + host = settings.audit_siem_syslog_host + port = settings.audit_siem_syslog_port + + if proto == "tcp": + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(5) + sock.connect((host, port)) + sock.sendall(syslog_msg.encode("utf-8")) + else: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.settimeout(5) + sock.sendto(syslog_msg.encode("utf-8"), (host, port)) + + logger.debug("Syslog audit event sent to %s:%s (%s)", host, port, proto) + + +def _send_http(payload: dict[str, Any]) -> None: + """POST a JSON audit event to the configured HTTP endpoint.""" + url = settings.audit_siem_http_url + if not url: + logger.warning("SIEM HTTP URL not configured; skipping HTTP forwarding") + return + + headers: dict[str, str] = {"Content-Type": "application/json"} + token = settings.audit_siem_http_token + if token: + headers["Authorization"] = f"Bearer {token}" + + # Parse custom headers (comma-separated "Key:Value" pairs). + # Reject headers that could override security-critical ones already set, + # and validate that header names contain only RFC 7230 token characters. + _PROTECTED_HEADERS = {"authorization", "content-type", "host"} + _VALID_HEADER_NAME = re.compile(r"^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$") + raw_custom = settings.audit_siem_http_custom_headers + if raw_custom: + for raw_pair in raw_custom.split(","): + pair = raw_pair.strip() + if ":" in pair: + k, _, v = pair.partition(":") + name = k.strip() + if not name or not _VALID_HEADER_NAME.match(name): + logger.warning("Skipping invalid SIEM custom header name: %r", name) + continue + if name.lower() in _PROTECTED_HEADERS: + logger.warning("Skipping protected SIEM custom header: %r", name) + continue + headers[name] = v.strip() + + # Wrap in Splunk HEC-style envelope when URL contains ``/services/collector``. + body: dict[str, Any] + if "/services/collector" in url: + body = {"event": payload, "sourcetype": "docuelevate:audit", "source": "docuelevate"} + else: + body = payload + + with httpx.Client(timeout=10) as client: + resp = client.post(url, json=body, headers=headers) + resp.raise_for_status() + + logger.debug("HTTP audit event forwarded to %s (status %s)", url, resp.status_code) diff --git a/app/utils/db_migrate.py b/app/utils/db_migrate.py index f95d97f0..3424009d 100644 --- a/app/utils/db_migrate.py +++ b/app/utils/db_migrate.py @@ -32,8 +32,10 @@ _TABLE_ORDER = [ "processing_logs", "application_settings", "settings_audit_log", + "audit_logs", "saved_searches", "webhook_configs", + "shared_links", ] diff --git a/app/utils/i18n.py b/app/utils/i18n.py new file mode 100644 index 00000000..04055adc --- /dev/null +++ b/app/utils/i18n.py @@ -0,0 +1,539 @@ +"""Internationalization (i18n) and localization (l10n) utilities. + +Provides a JSON-based translation system for the DocuElevate UI with: + +* **31 supported languages** covering all major European languages plus ZH +* Browser ``Accept-Language`` detection with cookie & user-profile persistence +* AI-powered fallback translation via the configured LLM provider +* Locale-aware date, number, and file-size formatting helpers +* Jinja2 integration via a ``_()`` global function + +Language resolution order: + 1. User profile ``preferred_language`` (persisted in DB) + 2. ``docuelevate_lang`` cookie + 3. ``Accept-Language`` HTTP header + 4. Default (``en``) +""" + +from __future__ import annotations + +import json +import logging +from datetime import date, datetime +from functools import lru_cache +from pathlib import Path +from typing import Any + +from starlette.requests import Request + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Supported languages (ordered by priority) +# --------------------------------------------------------------------------- + +SUPPORTED_LANGUAGES: list[dict[str, str]] = [ + # --- Tier 1: Primary European languages --- + {"code": "en", "name": "English", "native": "English", "flag": "🇬🇧"}, + {"code": "de", "name": "German", "native": "Deutsch", "flag": "🇩🇪"}, + {"code": "fr", "name": "French", "native": "Français", "flag": "🇫🇷"}, + {"code": "es", "name": "Spanish", "native": "Español", "flag": "🇪🇸"}, + {"code": "it", "name": "Italian", "native": "Italiano", "flag": "🇮🇹"}, + {"code": "pt", "name": "Portuguese", "native": "Português", "flag": "🇵🇹"}, + # --- Tier 2: Western & Northern European --- + {"code": "nl", "name": "Dutch", "native": "Nederlands", "flag": "🇳🇱"}, + {"code": "nb", "name": "Norwegian", "native": "Norsk", "flag": "🇳🇴"}, + {"code": "da", "name": "Danish", "native": "Dansk", "flag": "🇩🇰"}, + {"code": "sv", "name": "Swedish", "native": "Svenska", "flag": "🇸🇪"}, + {"code": "fi", "name": "Finnish", "native": "Suomi", "flag": "🇫🇮"}, + {"code": "is", "name": "Icelandic", "native": "Íslenska", "flag": "🇮🇸"}, + {"code": "ga", "name": "Irish", "native": "Gaeilge", "flag": "🇮🇪"}, + {"code": "lb", "name": "Luxembourgish", "native": "Lëtzebuergesch", "flag": "🇱🇺"}, + {"code": "ca", "name": "Catalan", "native": "Català", "flag": "🏴"}, + # --- Tier 3: Central & Eastern European --- + {"code": "pl", "name": "Polish", "native": "Polski", "flag": "🇵🇱"}, + {"code": "cs", "name": "Czech", "native": "Čeština", "flag": "🇨🇿"}, + {"code": "sk", "name": "Slovak", "native": "Slovenčina", "flag": "🇸🇰"}, + {"code": "hu", "name": "Hungarian", "native": "Magyar", "flag": "🇭🇺"}, + {"code": "sl", "name": "Slovenian", "native": "Slovenščina", "flag": "🇸🇮"}, + {"code": "hr", "name": "Croatian", "native": "Hrvatski", "flag": "🇭🇷"}, + {"code": "ro", "name": "Romanian", "native": "Română", "flag": "🇷🇴"}, + {"code": "bg", "name": "Bulgarian", "native": "Български", "flag": "🇧🇬"}, + {"code": "el", "name": "Greek", "native": "Ελληνικά", "flag": "🇬🇷"}, + {"code": "et", "name": "Estonian", "native": "Eesti", "flag": "🇪🇪"}, + {"code": "lv", "name": "Latvian", "native": "Latviešu", "flag": "🇱🇻"}, + {"code": "lt", "name": "Lithuanian", "native": "Lietuvių", "flag": "🇱🇹"}, + # --- Tier 4: Non-EU European & Other --- + {"code": "tr", "name": "Turkish", "native": "Türkçe", "flag": "🇹🇷"}, + {"code": "uk", "name": "Ukrainian", "native": "Українська", "flag": "🇺🇦"}, + {"code": "ru", "name": "Russian", "native": "Русский", "flag": "🇷🇺"}, + {"code": "zh", "name": "Chinese", "native": "中文", "flag": "🇨🇳"}, +] + +SUPPORTED_LANGUAGE_CODES: set[str] = {lang["code"] for lang in SUPPORTED_LANGUAGES} +DEFAULT_LANGUAGE = "en" + +# --------------------------------------------------------------------------- +# Translation file loading +# --------------------------------------------------------------------------- + +_TRANSLATIONS_DIR = Path(__file__).resolve().parent.parent.parent / "frontend" / "translations" +_translation_cache: dict[str, dict[str, str]] = {} + + +def _load_translations(locale: str) -> dict[str, str]: + """Load the translation JSON file for *locale*, with caching.""" + if locale in _translation_cache: + return _translation_cache[locale] + + filepath = _TRANSLATIONS_DIR / f"{locale}.json" + if not filepath.is_file(): + logger.warning("Translation file not found for locale '%s'", locale) + _translation_cache[locale] = {} + return {} + + try: + data: dict[str, str] = json.loads(filepath.read_text(encoding="utf-8")) + _translation_cache[locale] = data + return data + except (json.JSONDecodeError, OSError): + logger.exception("Failed to load translations for '%s'", locale) + _translation_cache[locale] = {} + return {} + + +def reload_translations() -> None: + """Clear the translation cache so files are re-read on next access.""" + _translation_cache.clear() + + +# --------------------------------------------------------------------------- +# Core translation function +# --------------------------------------------------------------------------- + + +def translate(key: str, locale: str | None = None, **kwargs: Any) -> str: + """Return the translated string for *key* in *locale*. + + Falls back through: + 1. Requested *locale* + 2. English (``en``) + 3. The raw key itself (to keep the UI functional) + + Positional placeholders ``{0}``, ``{1}`` or named placeholders + ``{name}`` in the translated string are interpolated via *kwargs*. + """ + locale = locale if locale and locale in SUPPORTED_LANGUAGE_CODES else DEFAULT_LANGUAGE + + translations = _load_translations(locale) + value = translations.get(key) + + # Fallback to English + if value is None and locale != DEFAULT_LANGUAGE: + en_translations = _load_translations(DEFAULT_LANGUAGE) + value = en_translations.get(key) + + # Fallback to key itself + if value is None: + value = key + + if kwargs: + try: + value = value.format(**kwargs) + except (KeyError, IndexError): + pass # Return unformatted string rather than crash + + return value + + +# --------------------------------------------------------------------------- +# AI fallback translation (best-effort, non-blocking) +# --------------------------------------------------------------------------- + +_ai_translation_cache: dict[tuple[str, str], str] = {} + + +def translate_with_ai_fallback(text: str, target_locale: str) -> str: + """Translate *text* using the configured AI provider as a fallback. + + Returns the original *text* unchanged when: + * The target locale is English (source language) + * The AI provider is unavailable or returns an error + * The translation has already been cached + + Results are cached in-memory for the lifetime of the process. + """ + if target_locale == DEFAULT_LANGUAGE or target_locale not in SUPPORTED_LANGUAGE_CODES: + return text + + cache_key = (text, target_locale) + if cache_key in _ai_translation_cache: + return _ai_translation_cache[cache_key] + + target_name = next( + (lang["name"] for lang in SUPPORTED_LANGUAGES if lang["code"] == target_locale), + target_locale, + ) + + try: + from litellm import completion # type: ignore[import-untyped] + + from app.config import settings + + model = getattr(settings, "ai_model", None) or getattr(settings, "openai_model", "gpt-4o-mini") + response = completion( + model=model, + messages=[ + { + "role": "system", + "content": ( + f"You are a professional translator. Translate the following UI text " + f"from English to {target_name}. Return ONLY the translated text, " + f"nothing else. Keep any HTML tags, placeholders like {{name}}, " + f"and special characters intact." + ), + }, + {"role": "user", "content": text}, + ], + max_tokens=256, + temperature=0.1, + ) + translated = response.choices[0].message.content.strip() + _ai_translation_cache[cache_key] = translated + return translated + except Exception: + logger.debug("AI fallback translation failed for '%s' → %s", text[:50], target_locale) + return text + + +# --------------------------------------------------------------------------- +# Language detection +# --------------------------------------------------------------------------- + + +def detect_language(request: Request) -> str: + """Determine the preferred UI language from the request context. + + Resolution order: + 1. ``preferred_language`` stored in the user session + 2. ``docuelevate_lang`` cookie + 3. ``Accept-Language`` HTTP header (best match) + 4. Default → ``en`` + """ + # 1. User session preference + if hasattr(request, "session"): + session_lang = request.session.get("preferred_language") + if session_lang and session_lang in SUPPORTED_LANGUAGE_CODES: + return session_lang + + # 2. Cookie + cookie_lang = request.cookies.get("docuelevate_lang") + if cookie_lang and cookie_lang in SUPPORTED_LANGUAGE_CODES: + return cookie_lang + + # 3. Accept-Language header + accept = request.headers.get("accept-language", "") + lang = _parse_accept_language(accept) + if lang: + return lang + + return DEFAULT_LANGUAGE + + +def _parse_accept_language(header: str) -> str | None: + """Extract the best matching language from an ``Accept-Language`` header. + + Parses quality values and returns the highest-priority match among + :data:`SUPPORTED_LANGUAGE_CODES`, or ``None`` if nothing matches. + """ + if not header: + return None + + entries: list[tuple[float, str]] = [] + for raw_part in header.split(","): + part = raw_part.strip() + if not part: + continue + if ";q=" in part: + lang_tag, _, q_str = part.partition(";q=") + try: + quality = float(q_str.strip()) + except ValueError: + quality = 0.0 + else: + lang_tag = part + quality = 1.0 + entries.append((quality, lang_tag.strip().lower())) + + # Sort by quality descending + entries.sort(key=lambda e: e[0], reverse=True) + + for _quality, tag in entries: + # Try exact match first (e.g., "de", "zh") + code = tag.split("-")[0] + if code in SUPPORTED_LANGUAGE_CODES: + return code + + return None + + +# --------------------------------------------------------------------------- +# Localization helpers (l10n) +# --------------------------------------------------------------------------- + +# Locale-specific formatting rules for date/number display +_LOCALE_FORMATS: dict[str, dict[str, Any]] = { + "en": { + "date": "%B %d, %Y", + "date_short": "%m/%d/%Y", + "datetime": "%B %d, %Y %I:%M %p", + "thousands_sep": ",", + "decimal_sep": ".", + }, + "de": { + "date": "%d. %B %Y", + "date_short": "%d.%m.%Y", + "datetime": "%d. %B %Y %H:%M", + "thousands_sep": ".", + "decimal_sep": ",", + }, + "fr": { + "date": "%d %B %Y", + "date_short": "%d/%m/%Y", + "datetime": "%d %B %Y %H:%M", + "thousands_sep": "\u202f", + "decimal_sep": ",", + }, + "es": { + "date": "%d de %B de %Y", + "date_short": "%d/%m/%Y", + "datetime": "%d de %B de %Y %H:%M", + "thousands_sep": ".", + "decimal_sep": ",", + }, + "it": { + "date": "%d %B %Y", + "date_short": "%d/%m/%Y", + "datetime": "%d %B %Y %H:%M", + "thousands_sep": ".", + "decimal_sep": ",", + }, + "pt": { + "date": "%d de %B de %Y", + "date_short": "%d/%m/%Y", + "datetime": "%d de %B de %Y %H:%M", + "thousands_sep": ".", + "decimal_sep": ",", + }, + "nl": { + "date": "%d %B %Y", + "date_short": "%d-%m-%Y", + "datetime": "%d %B %Y %H:%M", + "thousands_sep": ".", + "decimal_sep": ",", + }, + "nb": { + "date": "%d. %B %Y", + "date_short": "%d.%m.%Y", + "datetime": "%d. %B %Y %H:%M", + "thousands_sep": "\u00a0", + "decimal_sep": ",", + }, + "da": { + "date": "%d. %B %Y", + "date_short": "%d.%m.%Y", + "datetime": "%d. %B %Y %H:%M", + "thousands_sep": ".", + "decimal_sep": ",", + }, + "sv": { + "date": "%d %B %Y", + "date_short": "%Y-%m-%d", + "datetime": "%d %B %Y %H:%M", + "thousands_sep": "\u00a0", + "decimal_sep": ",", + }, + "fi": { + "date": "%d. %B %Y", + "date_short": "%d.%m.%Y", + "datetime": "%d. %B %Y %H:%M", + "thousands_sep": "\u00a0", + "decimal_sep": ",", + }, + "is": { + "date": "%d. %B %Y", + "date_short": "%d.%m.%Y", + "datetime": "%d. %B %Y %H:%M", + "thousands_sep": ".", + "decimal_sep": ",", + }, + "ga": { + "date": "%d %B %Y", + "date_short": "%d/%m/%Y", + "datetime": "%d %B %Y %H:%M", + "thousands_sep": ",", + "decimal_sep": ".", + }, + "lb": { + "date": "%d. %B %Y", + "date_short": "%d.%m.%Y", + "datetime": "%d. %B %Y %H:%M", + "thousands_sep": ".", + "decimal_sep": ",", + }, + "ca": { + "date": "%d de %B de %Y", + "date_short": "%d/%m/%Y", + "datetime": "%d de %B de %Y %H:%M", + "thousands_sep": ".", + "decimal_sep": ",", + }, + "pl": { + "date": "%d %B %Y", + "date_short": "%d.%m.%Y", + "datetime": "%d %B %Y %H:%M", + "thousands_sep": "\u00a0", + "decimal_sep": ",", + }, + "cs": { + "date": "%d. %B %Y", + "date_short": "%d.%m.%Y", + "datetime": "%d. %B %Y %H:%M", + "thousands_sep": "\u00a0", + "decimal_sep": ",", + }, + "sk": { + "date": "%d. %B %Y", + "date_short": "%d.%m.%Y", + "datetime": "%d. %B %Y %H:%M", + "thousands_sep": "\u00a0", + "decimal_sep": ",", + }, + "hu": { + "date": "%Y. %B %d.", + "date_short": "%Y.%m.%d.", + "datetime": "%Y. %B %d. %H:%M", + "thousands_sep": "\u00a0", + "decimal_sep": ",", + }, + "sl": { + "date": "%d. %B %Y", + "date_short": "%d.%m.%Y", + "datetime": "%d. %B %Y %H:%M", + "thousands_sep": ".", + "decimal_sep": ",", + }, + "hr": { + "date": "%d. %B %Y.", + "date_short": "%d.%m.%Y.", + "datetime": "%d. %B %Y. %H:%M", + "thousands_sep": ".", + "decimal_sep": ",", + }, + "ro": { + "date": "%d %B %Y", + "date_short": "%d.%m.%Y", + "datetime": "%d %B %Y %H:%M", + "thousands_sep": ".", + "decimal_sep": ",", + }, + "bg": { + "date": "%d %B %Y", + "date_short": "%d.%m.%Y", + "datetime": "%d %B %Y %H:%M", + "thousands_sep": "\u00a0", + "decimal_sep": ",", + }, + "el": { + "date": "%d %B %Y", + "date_short": "%d/%m/%Y", + "datetime": "%d %B %Y %H:%M", + "thousands_sep": ".", + "decimal_sep": ",", + }, + "et": { + "date": "%d. %B %Y", + "date_short": "%d.%m.%Y", + "datetime": "%d. %B %Y %H:%M", + "thousands_sep": "\u00a0", + "decimal_sep": ",", + }, + "lv": { + "date": "%Y. gada %d. %B", + "date_short": "%d.%m.%Y.", + "datetime": "%Y. gada %d. %B %H:%M", + "thousands_sep": "\u00a0", + "decimal_sep": ",", + }, + "lt": { + "date": "%Y m. %B %d d.", + "date_short": "%Y-%m-%d", + "datetime": "%Y m. %B %d d. %H:%M", + "thousands_sep": "\u00a0", + "decimal_sep": ",", + }, + "tr": { + "date": "%d %B %Y", + "date_short": "%d.%m.%Y", + "datetime": "%d %B %Y %H:%M", + "thousands_sep": ".", + "decimal_sep": ",", + }, + "uk": { + "date": "%d %B %Y", + "date_short": "%d.%m.%Y", + "datetime": "%d %B %Y %H:%M", + "thousands_sep": "\u00a0", + "decimal_sep": ",", + }, + "zh": { + "date": "%Y年%m月%d日", + "date_short": "%Y/%m/%d", + "datetime": "%Y年%m月%d日 %H:%M", + "thousands_sep": ",", + "decimal_sep": ".", + }, + "ru": { + "date": "%d %B %Y", + "date_short": "%d.%m.%Y", + "datetime": "%d %B %Y %H:%M", + "thousands_sep": "\u00a0", + "decimal_sep": ",", + }, +} + + +def format_date(value: date | datetime | None, locale: str = DEFAULT_LANGUAGE, short: bool = False) -> str: + """Format a date/datetime value according to the locale conventions.""" + if value is None: + return "" + fmt_key = "date_short" if short else "date" + fmt = _LOCALE_FORMATS.get(locale, _LOCALE_FORMATS[DEFAULT_LANGUAGE])[fmt_key] + return value.strftime(fmt) + + +def format_datetime(value: datetime | None, locale: str = DEFAULT_LANGUAGE) -> str: + """Format a datetime value according to the locale conventions.""" + if value is None: + return "" + fmt = _LOCALE_FORMATS.get(locale, _LOCALE_FORMATS[DEFAULT_LANGUAGE])["datetime"] + return value.strftime(fmt) + + +def format_number(value: int | float, locale: str = DEFAULT_LANGUAGE) -> str: + """Format a number with locale-appropriate thousand separators.""" + lf = _LOCALE_FORMATS.get(locale, _LOCALE_FORMATS[DEFAULT_LANGUAGE]) + if isinstance(value, float): + int_part, _, dec_part = f"{value:,.2f}".partition(".") + formatted_int = int_part.replace(",", lf["thousands_sep"]) + return f"{formatted_int}{lf['decimal_sep']}{dec_part}" + return f"{value:,}".replace(",", lf["thousands_sep"]) + + +@lru_cache(maxsize=32) +def get_language_info(code: str) -> dict[str, str] | None: + """Return the metadata dict for a supported language code, or ``None``.""" + for lang in SUPPORTED_LANGUAGES: + if lang["code"] == code: + return lang + return None diff --git a/app/utils/settings_service.py b/app/utils/settings_service.py index 7516f8f9..39ed812e 100644 --- a/app/utils/settings_service.py +++ b/app/utils/settings_service.py @@ -2065,6 +2065,78 @@ SETTING_METADATA = { "required": False, "restart_required": True, }, + "audit_siem_enabled": { + "category": "Security", + "description": "Enable forwarding of audit events to an external SIEM system (Syslog, Splunk, Logstash, etc.).", + "type": "boolean", + "sensitive": False, + "required": False, + "restart_required": False, + }, + "audit_siem_transport": { + "category": "Security", + "description": ( + "Transport used to forward audit events. 'syslog' sends RFC 5424 messages over UDP/TCP. " + "'http' sends JSON POST payloads to a webhook URL (Splunk HEC, Logstash, Grafana Loki, etc.)." + ), + "type": "string", + "sensitive": False, + "required": False, + "restart_required": False, + "options": ["syslog", "http"], + }, + "audit_siem_syslog_host": { + "category": "Security", + "description": "Hostname or IP of the syslog receiver.", + "type": "string", + "sensitive": False, + "required": False, + "restart_required": False, + }, + "audit_siem_syslog_port": { + "category": "Security", + "description": "Port of the syslog receiver. Default: 514.", + "type": "integer", + "sensitive": False, + "required": False, + "restart_required": False, + }, + "audit_siem_syslog_protocol": { + "category": "Security", + "description": "Protocol for syslog transport: 'udp' or 'tcp'. Default: udp.", + "type": "string", + "sensitive": False, + "required": False, + "restart_required": False, + "options": ["udp", "tcp"], + }, + "audit_siem_http_url": { + "category": "Security", + "description": ( + "HTTP endpoint URL for SIEM webhook delivery. Supports Splunk HEC, " + "Logstash HTTP input, Grafana Loki push API, or any JSON-accepting endpoint." + ), + "type": "string", + "sensitive": False, + "required": False, + "restart_required": False, + }, + "audit_siem_http_token": { + "category": "Security", + "description": "Bearer / HEC token included in the Authorization header of SIEM HTTP requests.", + "type": "string", + "sensitive": True, + "required": False, + "restart_required": False, + }, + "audit_siem_http_custom_headers": { + "category": "Security", + "description": "Comma-separated 'Key:Value' pairs of extra headers for SIEM HTTP requests.", + "type": "string", + "sensitive": False, + "required": False, + "restart_required": False, + }, # Rate Limiting "rate_limiting_enabled": { "category": "Security", diff --git a/app/views/__init__.py b/app/views/__init__.py index 5c098527..1b2b0443 100644 --- a/app/views/__init__.py +++ b/app/views/__init__.py @@ -6,6 +6,7 @@ from fastapi import APIRouter from app.views.admin_users import router as admin_users_router from app.views.api_tokens import router as api_tokens_router +from app.views.audit_logs import router as audit_logs_router from app.views.backup import router as backup_router from app.views.db_wizard import router as db_wizard_router from app.views.dropbox import router as dropbox_router @@ -60,4 +61,5 @@ router.include_router(imap_accounts_router) # Per-user IMAP ingestion accounts router.include_router(integrations_router) # Unified integrations dashboard router.include_router(notifications_router) # User notification dashboard router.include_router(scheduled_jobs_router) # Admin scheduled batch jobs +router.include_router(audit_logs_router) # Comprehensive audit log viewer router.include_router(help_router) # Built-in help / How-To docs diff --git a/app/views/audit_logs.py b/app/views/audit_logs.py new file mode 100644 index 00000000..a787fece --- /dev/null +++ b/app/views/audit_logs.py @@ -0,0 +1,46 @@ +""" +Audit log viewer UI — admin-only page with filtering and SIEM status. +""" + +import logging + +from fastapi import Depends, HTTPException, Request, status +from sqlalchemy.orm import Session + +from app.views.base import APIRouter, get_db, require_login, settings, templates +from app.views.settings import require_admin_access + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.get("/admin/audit-logs") +@require_login +@require_admin_access +async def audit_logs_page(request: Request, db: Session = Depends(get_db)): + """Comprehensive audit log viewer with filtering controls. + + Displays a chronological log of all significant actions: logins, + document operations, settings changes, and admin actions. The + actual data is fetched client-side via the ``/api/audit-logs`` JSON + endpoint so that filters, pagination, and live refresh work without + full-page reloads. + """ + try: + siem_enabled = settings.audit_siem_enabled + siem_transport = settings.audit_siem_transport if siem_enabled else None + return templates.TemplateResponse( + "audit_logs.html", + { + "request": request, + "app_version": settings.version, + "siem_enabled": siem_enabled, + "siem_transport": siem_transport, + }, + ) + except Exception as e: + logger.error("Error loading audit logs page: %s", e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to load audit logs page", + ) diff --git a/app/views/base.py b/app/views/base.py index 010d4f21..fc9d5e0d 100644 --- a/app/views/base.py +++ b/app/views/base.py @@ -12,6 +12,14 @@ from sqlalchemy.orm import Session # noqa: F401 from app.auth import require_login # noqa: F401 from app.config import settings from app.database import get_db # noqa: F401 +from app.utils.i18n import ( + SUPPORTED_LANGUAGES, + detect_language, + format_date, + format_datetime, + format_number, + translate, +) # Set up Jinja2 templates templates_dir = Path(__file__).parent.parent.parent / "frontend" / "templates" @@ -21,6 +29,16 @@ templates = Jinja2Templates(directory=str(templates_dir)) templates.env.globals["min"] = min templates.env.globals["max"] = max +# --------------------------------------------------------------------------- +# i18n Jinja2 integration +# --------------------------------------------------------------------------- +# The _() function is available in every template to translate UI strings. +# Usage: {{ _("nav.dashboard") }} or {{ _("upload.max_size", size="10 MB") }} +# The locale is automatically resolved from the request context. +# --------------------------------------------------------------------------- + +templates.env.globals["supported_languages"] = SUPPORTED_LANGUAGES + # Customize Jinja2Templates to include app_version in all templates original_template_response = templates.TemplateResponse @@ -48,8 +66,31 @@ def _inject_global_context(ctx: dict) -> None: session_user = req.session.get("user") # When auth is disabled every visitor is effectively "logged in" ctx.setdefault("is_logged_in", not getattr(settings, "auth_enabled", True) or session_user is not None) + + # --- i18n: detect language and register template helpers --- + current_locale = detect_language(req) + ctx.setdefault("current_locale", current_locale) + + def _translate(key: str, **kwargs: object) -> str: + return translate(key, current_locale, **kwargs) + + def _format_date(value: object, short: bool = False) -> str: + return format_date(value, current_locale, short=short) # type: ignore[arg-type] + + def _format_datetime(value: object) -> str: + return format_datetime(value, current_locale) # type: ignore[arg-type] + + def _format_number(value: object) -> str: + return format_number(value, current_locale) # type: ignore[arg-type] + + ctx.setdefault("_", _translate) + ctx.setdefault("format_date_l10n", _format_date) + ctx.setdefault("format_datetime_l10n", _format_datetime) + ctx.setdefault("format_number_l10n", _format_number) else: ctx.setdefault("is_logged_in", not getattr(settings, "auth_enabled", True)) + ctx.setdefault("current_locale", "en") + ctx.setdefault("_", lambda key, **kw: translate(key, "en", **kw)) def template_response_with_version(*args, **kwargs): diff --git a/migrations/env.py b/migrations/env.py index 19785a0c..086b4ba5 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -20,14 +20,30 @@ from app.database import Base # Ensure all models are imported so Base.metadata is populated. from app.models import ( # noqa: F401 + ApiToken, ApplicationSettings, + AuditLog, + BackupRecord, DocumentMetadata, FileProcessingStep, FileRecord, + InAppNotification, + LocalUser, MobileDevice, + Pipeline, + PipelineStep, ProcessingLog, SavedSearch, + ScheduledJob, SettingsAuditLog, + SharedLink, + SubscriptionPlan, + UserImapAccount, + UserIntegration, + UserNotificationPreference, + UserNotificationTarget, + UserProfile, + WebhookConfig, ) # Alembic Config object – provides access to values in alembic.ini. diff --git a/migrations/versions/027_ensure_shared_links_table.py b/migrations/versions/027_ensure_shared_links_table.py new file mode 100644 index 00000000..2a333582 --- /dev/null +++ b/migrations/versions/027_ensure_shared_links_table.py @@ -0,0 +1,62 @@ +"""Ensure shared_links table exists for databases that skipped migration 025. + +Databases that were already at revision 025_add_user_notifications or +026_add_scheduled_jobs before 025_add_shared_links was inserted into the +migration chain will never have had the ``shared_links`` table created. +This migration creates the table idempotently so those databases are +repaired on the next ``alembic upgrade head``. + +Revision ID: 027_ensure_shared_links_table +Revises: 026_add_scheduled_jobs +Create Date: 2026-03-09 +""" + +from typing import Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "027_ensure_shared_links_table" +down_revision: Union[str, None] = "026_add_scheduled_jobs" +depends_on: Union[str, None] = None + + +def upgrade() -> None: + """Create shared_links table if it does not already exist.""" + conn = op.get_bind() + inspector = sa.inspect(conn) + if "shared_links" not in inspector.get_table_names(): + op.create_table( + "shared_links", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("token", sa.String(64), nullable=False), + sa.Column("file_id", sa.Integer(), nullable=False), + sa.Column("owner_id", sa.String(), nullable=False), + sa.Column("label", sa.String(255), nullable=True), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("max_views", sa.Integer(), nullable=True), + sa.Column("view_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("password_hash", sa.String(128), nullable=True), + 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("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["file_id"], ["files.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("token"), + ) + op.create_index("ix_shared_links_id", "shared_links", ["id"]) + op.create_index("ix_shared_links_token", "shared_links", ["token"]) + op.create_index("ix_shared_links_file_id", "shared_links", ["file_id"]) + op.create_index("ix_shared_links_owner_id", "shared_links", ["owner_id"]) + + +def downgrade() -> None: + """Drop shared_links table only if this migration created it.""" + conn = op.get_bind() + inspector = sa.inspect(conn) + if "shared_links" in inspector.get_table_names(): + op.drop_index("ix_shared_links_owner_id", "shared_links") + op.drop_index("ix_shared_links_file_id", "shared_links") + op.drop_index("ix_shared_links_token", "shared_links") + op.drop_index("ix_shared_links_id", "shared_links") + op.drop_table("shared_links") diff --git a/migrations/versions/028_add_audit_logs.py b/migrations/versions/028_add_audit_logs.py new file mode 100644 index 00000000..58114e7a --- /dev/null +++ b/migrations/versions/028_add_audit_logs.py @@ -0,0 +1,47 @@ +"""Add audit_logs table for comprehensive compliance audit logging. + +Revision ID: 028_add_audit_logs +Revises: 027_ensure_shared_links_table +Create Date: 2026-03-09 +""" + +from typing import Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "028_add_audit_logs" +down_revision: Union[str, None] = "027_ensure_shared_links_table" +depends_on: Union[str, None] = None + + +def upgrade() -> None: + """Create audit_logs table.""" + op.create_table( + "audit_logs", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("timestamp", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("user", sa.String(), nullable=False), + sa.Column("action", sa.String(), nullable=False), + sa.Column("resource_type", sa.String(), nullable=True), + sa.Column("resource_id", sa.String(), nullable=True), + sa.Column("ip_address", sa.String(), nullable=True), + sa.Column("details", sa.Text(), nullable=True), + sa.Column("severity", sa.String(16), nullable=False, server_default="info"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_audit_logs_id", "audit_logs", ["id"]) + op.create_index("ix_audit_logs_timestamp", "audit_logs", ["timestamp"]) + op.create_index("ix_audit_logs_user", "audit_logs", ["user"]) + op.create_index("ix_audit_logs_action", "audit_logs", ["action"]) + op.create_index("ix_audit_logs_resource_type", "audit_logs", ["resource_type"]) + + +def downgrade() -> None: + """Drop audit_logs table.""" + op.drop_index("ix_audit_logs_resource_type", "audit_logs") + op.drop_index("ix_audit_logs_action", "audit_logs") + op.drop_index("ix_audit_logs_user", "audit_logs") + op.drop_index("ix_audit_logs_timestamp", "audit_logs") + op.drop_index("ix_audit_logs_id", "audit_logs") + op.drop_table("audit_logs") diff --git a/migrations/versions/029_add_user_language_preference.py b/migrations/versions/029_add_user_language_preference.py new file mode 100644 index 00000000..632f0040 --- /dev/null +++ b/migrations/versions/029_add_user_language_preference.py @@ -0,0 +1,28 @@ +"""Add preferred_language column to user_profiles for i18n support. + +Revision ID: 029_add_user_language_preference +Revises: 028_add_audit_logs +Create Date: 2026-03-09 +""" + +from typing import Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "029_add_user_language_preference" +down_revision: Union[str, None] = "028_add_audit_logs" +depends_on: Union[str, None] = None + + +def upgrade() -> None: + """Add preferred_language column to user_profiles table.""" + op.add_column( + "user_profiles", + sa.Column("preferred_language", sa.String(10), nullable=True, server_default=None), + ) + + +def downgrade() -> None: + """Remove preferred_language column from user_profiles table.""" + op.drop_column("user_profiles", "preferred_language") diff --git a/migrations/versions/027_add_mobile_devices.py b/migrations/versions/030_add_mobile_devices.py similarity index 88% rename from migrations/versions/027_add_mobile_devices.py rename to migrations/versions/030_add_mobile_devices.py index c9732be9..c73773ef 100644 --- a/migrations/versions/027_add_mobile_devices.py +++ b/migrations/versions/030_add_mobile_devices.py @@ -1,7 +1,7 @@ """Add mobile_devices table for push notification device registration. -Revision ID: 027_add_mobile_devices -Revises: 026_add_scheduled_jobs +Revision ID: 030_add_mobile_devices +Revises: 029_add_user_language_preference Create Date: 2026-03-10 """ @@ -10,8 +10,8 @@ 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" +revision: str = "030_add_mobile_devices" +down_revision: Union[str, None] = "029_add_user_language_preference" depends_on: Union[str, None] = None