fix: merge main branch and renumber migration 027→037
Resolve 3 merge conflicts and renumber the automation_hooks migration to follow main's migration chain (036_add_document_translation_fields). Conflicts resolved: - app/api/__init__.py: add automation_router alongside main's new routers - app/utils/settings_service.py: add automation_hooks_enabled alongside compliance_enabled - tests/conftest.py: add AutomationHook alongside AuditLog/ComplianceTemplate imports Migration renumbered: - 027_add_automation_hooks → 037_add_automation_hooks - down_revision: 026_add_scheduled_jobs → 036_add_document_translation_fields Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* 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 Linking from "expo-linking";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import * as WebBrowser from "expo-web-browser";
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
SECURE_STORE_API_TOKEN_KEY,
|
||||
SECURE_STORE_BASE_URL_KEY,
|
||||
SECURE_STORE_OWNER_ID_KEY,
|
||||
api,
|
||||
type WhoAmIResponse,
|
||||
} from "../services/api";
|
||||
|
||||
WebBrowser.maybeCompleteAuthSession();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AuthState {
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
user: WhoAmIResponse | null;
|
||||
baseUrl: string;
|
||||
signIn: (serverUrl: string) => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
setToken: (token: string) => Promise<void>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AuthContext = createContext<AuthState>({
|
||||
isLoading: true,
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
baseUrl: "",
|
||||
signIn: async () => {},
|
||||
signOut: async () => {},
|
||||
setToken: async () => {},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [user, setUser] = useState<WhoAmIResponse | null>(null);
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
|
||||
// On mount: restore persisted session
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const storedUrl = await SecureStore.getItemAsync(SECURE_STORE_BASE_URL_KEY);
|
||||
const storedToken = await SecureStore.getItemAsync(SECURE_STORE_API_TOKEN_KEY);
|
||||
|
||||
if (storedUrl && storedToken) {
|
||||
await api.init(storedUrl);
|
||||
setBaseUrl(storedUrl);
|
||||
// Verify token is still valid
|
||||
const profile = await api.whoAmI();
|
||||
setUser(profile);
|
||||
setIsAuthenticated(true);
|
||||
}
|
||||
} catch {
|
||||
// Token expired or server unavailable – clear stored credentials
|
||||
await SecureStore.deleteItemAsync(SECURE_STORE_API_TOKEN_KEY);
|
||||
await SecureStore.deleteItemAsync(SECURE_STORE_OWNER_ID_KEY);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const setToken = useCallback(async (token: string) => {
|
||||
await SecureStore.setItemAsync(SECURE_STORE_API_TOKEN_KEY, token);
|
||||
const profile = await api.whoAmI();
|
||||
setUser(profile);
|
||||
await SecureStore.setItemAsync(SECURE_STORE_OWNER_ID_KEY, profile.owner_id);
|
||||
setIsAuthenticated(true);
|
||||
}, []);
|
||||
|
||||
const signIn = useCallback(
|
||||
async (serverUrl: string) => {
|
||||
const cleanUrl = serverUrl.replace(/\/$/, "");
|
||||
await api.init(cleanUrl);
|
||||
setBaseUrl(cleanUrl);
|
||||
|
||||
// Compute the correct redirect URI for the current runtime:
|
||||
// • Expo Go (development) → exp://<host>:<port>/--/callback
|
||||
// • Standalone / EAS build → docuelevate://callback
|
||||
// Both schemes are accepted by the server; using Linking.createURL
|
||||
// ensures the browser deep-link resolves correctly in every environment.
|
||||
const redirectUri = Linking.createURL("callback");
|
||||
|
||||
// Open the web login page in the system browser. The user authenticates
|
||||
// via SSO or local credentials, then the app deep-link is triggered.
|
||||
// The WebBrowser.openAuthSessionAsync handles the redirect back to the app.
|
||||
const result = await WebBrowser.openAuthSessionAsync(
|
||||
`${cleanUrl}/login?mobile=1&redirect_uri=${encodeURIComponent(redirectUri)}`,
|
||||
redirectUri
|
||||
);
|
||||
|
||||
if (result.type !== "success") {
|
||||
throw new Error("Authentication was cancelled or failed");
|
||||
}
|
||||
|
||||
// Parse the token from the redirect URL if the server appended it,
|
||||
// otherwise hit the generate-token endpoint (session cookie is carried
|
||||
// by the WebBrowser).
|
||||
const url = new URL(result.url);
|
||||
const inlineToken = url.searchParams.get("token");
|
||||
|
||||
if (inlineToken) {
|
||||
await setToken(inlineToken);
|
||||
} else {
|
||||
// The server set a session cookie during the browser session; exchange
|
||||
// it for a persistent API token.
|
||||
const deviceInfo = await _getDeviceName();
|
||||
const tokenResp = await api.generateMobileToken(deviceInfo);
|
||||
await setToken(tokenResp.token);
|
||||
}
|
||||
},
|
||||
[setToken]
|
||||
);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
await SecureStore.deleteItemAsync(SECURE_STORE_API_TOKEN_KEY);
|
||||
await SecureStore.deleteItemAsync(SECURE_STORE_OWNER_ID_KEY);
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
isLoading,
|
||||
isAuthenticated,
|
||||
user,
|
||||
baseUrl,
|
||||
signIn,
|
||||
signOut,
|
||||
setToken,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useAuth(): AuthState {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function _getDeviceName(): Promise<string> {
|
||||
try {
|
||||
const Constants = await import("expo-constants");
|
||||
return (
|
||||
Constants.default.deviceName ||
|
||||
Constants.default.expoConfig?.name ||
|
||||
"Mobile App"
|
||||
);
|
||||
} catch {
|
||||
return "Mobile App";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* ShareContext – propagates files received from the iOS Share Sheet or the
|
||||
* Android Share Intent to the UploadScreen so they can be uploaded
|
||||
* automatically.
|
||||
*
|
||||
* The root layout listens for incoming file:// / content:// URLs via
|
||||
* expo-linking and calls addPendingFile(). UploadScreen consumes the context,
|
||||
* uploads each pending file, then calls clearPendingFiles().
|
||||
*/
|
||||
|
||||
import React, { createContext, useCallback, useContext, useState } from "react";
|
||||
|
||||
export interface SharedFile {
|
||||
uri: string;
|
||||
filename: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
interface ShareContextValue {
|
||||
pendingFiles: SharedFile[];
|
||||
addPendingFile: (file: SharedFile) => void;
|
||||
clearPendingFiles: () => void;
|
||||
}
|
||||
|
||||
const ShareContext = createContext<ShareContextValue>({
|
||||
pendingFiles: [],
|
||||
addPendingFile: () => {},
|
||||
clearPendingFiles: () => {},
|
||||
});
|
||||
|
||||
export function ShareProvider({ children }: { children: React.ReactNode }) {
|
||||
const [pendingFiles, setPendingFiles] = useState<SharedFile[]>([]);
|
||||
|
||||
const addPendingFile = useCallback((file: SharedFile) => {
|
||||
setPendingFiles((prev) => [...prev, file]);
|
||||
}, []);
|
||||
|
||||
const clearPendingFiles = useCallback(() => {
|
||||
setPendingFiles([]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ShareContext.Provider value={{ pendingFiles, addPendingFile, clearPendingFiles }}>
|
||||
{children}
|
||||
</ShareContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useShare(): ShareContextValue {
|
||||
return useContext(ShareContext);
|
||||
}
|
||||
Reference in New Issue
Block a user