feat(mobile): add iOS/Android mobile app with SSO login, camera upload, and push notifications

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-10 09:52:02 +00:00
parent a50c3aadf5
commit d538c0879d
26 changed files with 3239 additions and 0 deletions
+199
View File
@@ -0,0 +1,199 @@
/**
* DocuElevate API client for the mobile app.
*
* All requests authenticate via a Bearer token stored in the device's secure
* keychain (via expo-secure-store). The token is obtained once through the
* SSO flow and cached until the user explicitly logs out.
*/
import * as SecureStore from "expo-secure-store";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
export const SECURE_STORE_API_TOKEN_KEY = "de_api_token";
export const SECURE_STORE_BASE_URL_KEY = "de_base_url";
export const SECURE_STORE_OWNER_ID_KEY = "de_owner_id";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface WhoAmIResponse {
owner_id: string;
display_name: string | null;
email: string | null;
avatar_url: string | null;
is_admin: boolean;
}
export interface GenerateTokenResponse {
token: string;
token_id: number;
name: string;
created_at: string;
}
export interface DeviceRegistration {
push_token: string;
device_name?: string;
platform: "ios" | "android" | "web";
}
export interface FileRecord {
id: number;
filename: string;
status: string;
created_at: string;
file_size: number | null;
content_type: string | null;
owner_id: string | null;
}
export interface UploadResponse {
task_id: string;
status: string;
message: string;
filename: string;
}
// ---------------------------------------------------------------------------
// Base API client
// ---------------------------------------------------------------------------
class DocuElevateAPI {
private baseUrl: string = "";
async init(baseUrl: string): Promise<void> {
this.baseUrl = baseUrl.replace(/\/$/, "");
await SecureStore.setItemAsync(SECURE_STORE_BASE_URL_KEY, this.baseUrl);
}
async loadFromStorage(): Promise<boolean> {
try {
const url = await SecureStore.getItemAsync(SECURE_STORE_BASE_URL_KEY);
if (url) {
this.baseUrl = url;
return true;
}
} catch {
// ignore
}
return false;
}
getBaseUrl(): string {
return this.baseUrl;
}
private async getToken(): Promise<string | null> {
try {
return await SecureStore.getItemAsync(SECURE_STORE_API_TOKEN_KEY);
} catch {
return null;
}
}
private async request<T>(
method: string,
path: string,
options?: { body?: unknown; formData?: FormData }
): Promise<T> {
const token = await this.getToken();
const headers: Record<string, string> = {};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
let body: BodyInit | undefined;
if (options?.formData) {
body = options.formData;
// Let fetch set multipart content-type with boundary automatically
} else if (options?.body !== undefined) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(options.body);
}
const response = await fetch(`${this.baseUrl}${path}`, {
method,
headers,
body,
});
if (!response.ok) {
let detail = `HTTP ${response.status}`;
try {
const err = await response.json();
detail = err.detail || JSON.stringify(err);
} catch {
// ignore
}
throw new Error(detail);
}
if (response.status === 204) {
return undefined as unknown as T;
}
return response.json();
}
// -------------------------------------------------------------------------
// Auth
// -------------------------------------------------------------------------
/** Exchange the current session (cookie) for a long-lived API token. */
async generateMobileToken(deviceName: string): Promise<GenerateTokenResponse> {
return this.request<GenerateTokenResponse>("POST", "/api/mobile/generate-token", {
body: { device_name: deviceName },
});
}
/** Return profile information for the authenticated user. */
async whoAmI(): Promise<WhoAmIResponse> {
return this.request<WhoAmIResponse>("GET", "/api/mobile/whoami");
}
// -------------------------------------------------------------------------
// Push notifications
// -------------------------------------------------------------------------
/** Register a push notification device token. */
async registerDevice(data: DeviceRegistration): Promise<void> {
await this.request("POST", "/api/mobile/register-device", { body: data });
}
/** Deactivate a device registration. */
async deactivateDevice(deviceId: number): Promise<void> {
await this.request("DELETE", `/api/mobile/devices/${deviceId}`);
}
// -------------------------------------------------------------------------
// Files
// -------------------------------------------------------------------------
/** Upload a file for processing. */
async uploadFile(uri: string, filename: string, mimeType?: string): Promise<UploadResponse> {
const formData = new FormData();
formData.append("file", {
uri,
name: filename,
type: mimeType || "application/octet-stream",
} as unknown as Blob);
return this.request<UploadResponse>("POST", "/api/ui-upload", { formData });
}
/** List recently processed files. */
async listFiles(page = 1, pageSize = 20): Promise<FileRecord[]> {
return this.request<FileRecord[]>(
"GET",
`/api/files?page=${page}&page_size=${pageSize}`
);
}
}
export const api = new DocuElevateAPI();
export default api;