238 lines
8.8 KiB
TypeScript
238 lines
8.8 KiB
TypeScript
import React, { useEffect, useState, useMemo } from "react";
|
|
import { Stack } from "expo-router";
|
|
import { StatusBar } from "expo-status-bar";
|
|
import { PortalHost } from "@rn-primitives/portal";
|
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
|
import { Toast } from "@/components/Toast";
|
|
import "@/global.css";
|
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
|
import {
|
|
View as RNView,
|
|
ActivityIndicator,
|
|
AppState,
|
|
} from "react-native";
|
|
import { NAV_THEME, loadTheme } from "@/lib/theme";
|
|
import { SirouRouterProvider, useSirouRouter } from "@sirou/react-native";
|
|
import { refreshTokens } from "@/lib/api-middlewares";
|
|
import { ThemeProvider, NavigationIndependentTree } from "@react-navigation/native";
|
|
import { routes } from "@/lib/routes";
|
|
import { authGuard, guestGuard } from "@/lib/auth-guards";
|
|
import { useAuthStore } from "@/lib/auth-store";
|
|
import { useFonts } from "expo-font";
|
|
import { useColorScheme } from "nativewind";
|
|
import { useSegments, useLocalSearchParams, useRouter } from "expo-router";
|
|
|
|
/**
|
|
* GlobalGuard: Handles all routing security and authentication redirects.
|
|
*/
|
|
function GlobalGuard() {
|
|
const segments = useSegments();
|
|
const params = useLocalSearchParams();
|
|
const router = useRouter();
|
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
|
const sirou = useSirouRouter();
|
|
const [isMounted, setIsMounted] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setIsMounted(true);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!isMounted || !segments) return;
|
|
|
|
const performGuardCheck = async () => {
|
|
const routeName = segments.length > 0 ? segments.join("/") : "root";
|
|
const isAuthPage =
|
|
segments[0] === "login" ||
|
|
segments[0] === "register" ||
|
|
segments[0] === "otp";
|
|
|
|
if (!isAuthenticated && !isAuthPage) {
|
|
console.log(`[GlobalGuard] Unauthorized on "${routeName}". Ejecting...`);
|
|
router.replace("/login");
|
|
return;
|
|
}
|
|
|
|
if (isAuthenticated && isAuthPage) {
|
|
console.log(`[GlobalGuard] Authenticated user on auth page. Sending home.`);
|
|
router.replace("/");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await (sirou as any).checkGuards(routeName, params);
|
|
if (!result.allowed && result.redirect) {
|
|
console.log(`[GlobalGuard] Sirou Guard Redirect -> ${result.redirect}`);
|
|
router.replace(result.redirect as any);
|
|
}
|
|
} catch (e: any) {
|
|
console.warn(`[GlobalGuard] guard crash:`, e.message);
|
|
}
|
|
};
|
|
|
|
performGuardCheck();
|
|
}, [segments, params, sirou, isMounted, isAuthenticated]);
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* SessionHeartbeat: Proactively refreshes tokens.
|
|
*/
|
|
function SessionHeartbeat() {
|
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
|
|
|
useEffect(() => {
|
|
if (!isAuthenticated) return;
|
|
|
|
const INTERVAL_MS = 5 * 60 * 1000;
|
|
const performRefresh = async (reason: string) => {
|
|
try {
|
|
console.log(`[SessionHeartbeat] Refresh triggered by: ${reason}`);
|
|
await refreshTokens();
|
|
} catch (err) {
|
|
console.warn(`[SessionHeartbeat] Refresh failed (${reason}):`, err);
|
|
}
|
|
};
|
|
|
|
performRefresh("Mount");
|
|
const interval = setInterval(() => performRefresh("Interval"), INTERVAL_MS);
|
|
|
|
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
|
if (nextAppState === "active") {
|
|
performRefresh("Foreground");
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
clearInterval(interval);
|
|
subscription.remove();
|
|
};
|
|
}, [isAuthenticated]);
|
|
|
|
return null;
|
|
}
|
|
|
|
export default function RootLayout() {
|
|
const { colorScheme, setColorScheme } = useColorScheme();
|
|
const [isMounted, setIsMounted] = useState(false);
|
|
const [hasHydrated, setHasHydrated] = useState(false);
|
|
const [isThemeRestored, setIsThemeRestored] = useState(false);
|
|
|
|
const [fontsLoaded] = useFonts({
|
|
"DMSans-Regular": require("../assets/fonts/DMSans-Regular.ttf"),
|
|
"DMSans-Bold": require("../assets/fonts/DMSans-Bold.ttf"),
|
|
"DMSans-Medium": require("../assets/fonts/DMSans-Medium.ttf"),
|
|
"DMSans-SemiBold": require("../assets/fonts/DMSans-SemiBold.ttf"),
|
|
"DMSans-Light": require("../assets/fonts/DMSans-Light.ttf"),
|
|
"DMSans-ExtraLight": require("../assets/fonts/DMSans-ExtraLight.ttf"),
|
|
"DMSans-Thin": require("../assets/fonts/DMSans-Thin.ttf"),
|
|
"DMSans-Black": require("../assets/fonts/DMSans-Black.ttf"),
|
|
"DMSans-ExtraBold": require("../assets/fonts/DMSans-ExtraBold.ttf"),
|
|
});
|
|
|
|
useEffect(() => {
|
|
setIsMounted(true);
|
|
|
|
// Auth Hydration
|
|
const initializeAuth = async () => {
|
|
if (useAuthStore.persist.hasHydrated()) {
|
|
setHasHydrated(true);
|
|
} else {
|
|
useAuthStore.persist.onFinishHydration(() => {
|
|
setHasHydrated(true);
|
|
});
|
|
}
|
|
};
|
|
|
|
// Theme Restoration
|
|
const initializeTheme = async () => {
|
|
try {
|
|
const savedTheme = await loadTheme();
|
|
if (savedTheme && savedTheme !== "system") {
|
|
setColorScheme(savedTheme);
|
|
}
|
|
} catch (e) {
|
|
console.warn("[RootLayout] Theme restore failed:", e);
|
|
} finally {
|
|
setIsThemeRestored(true);
|
|
}
|
|
};
|
|
|
|
initializeAuth();
|
|
initializeTheme();
|
|
}, []);
|
|
|
|
const theme = useMemo(() => {
|
|
const isDark = colorScheme === "dark";
|
|
return isDark ? NAV_THEME.dark : NAV_THEME.light;
|
|
}, [colorScheme]);
|
|
|
|
if (!isMounted || !hasHydrated || !fontsLoaded || !isThemeRestored) {
|
|
return (
|
|
<RNView
|
|
style={{
|
|
flex: 1,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
backgroundColor: "#ffffff",
|
|
}}
|
|
>
|
|
<ActivityIndicator size="large" color="#ea580c" />
|
|
</RNView>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<NavigationIndependentTree>
|
|
<ThemeProvider value={theme}>
|
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
|
<SafeAreaProvider>
|
|
<SirouRouterProvider config={routes} guards={[authGuard, guestGuard]}>
|
|
<RNView style={{ flex: 1, backgroundColor: theme.colors.background }}>
|
|
<StatusBar style={colorScheme === "dark" ? "light" : "dark"} />
|
|
<GlobalGuard />
|
|
<Stack
|
|
screenOptions={{
|
|
headerShown: false,
|
|
}}
|
|
>
|
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
|
<Stack.Screen name="sms-scan" options={{ headerShown: false }} />
|
|
<Stack.Screen name="proforma/[id]" options={{ title: "Proforma request" }} />
|
|
<Stack.Screen name="proforma/edit" options={{ title: "Edit Proforma" }} />
|
|
<Stack.Screen name="invoices/[id]" options={{ title: "Invoice" }} />
|
|
<Stack.Screen name="invoices/create" options={{ title: "Add Invoice", headerShown: false }} />
|
|
<Stack.Screen name="invoices/edit" options={{ title: "Edit Invoice" }} />
|
|
<Stack.Screen name="payments/[id]" options={{ title: "Payment" }} />
|
|
<Stack.Screen name="notifications/index" options={{ title: "Notifications" }} />
|
|
<Stack.Screen name="notifications/settings" options={{ title: "Notification settings" }} />
|
|
<Stack.Screen name="help" options={{ headerShown: false }} />
|
|
<Stack.Screen name="faq" options={{ headerShown: false }} />
|
|
<Stack.Screen name="terms" options={{ headerShown: false }} />
|
|
<Stack.Screen name="privacy" options={{ headerShown: false }} />
|
|
<Stack.Screen name="history" options={{ headerShown: false }} />
|
|
<Stack.Screen name="company" options={{ headerShown: false }} />
|
|
<Stack.Screen name="company-details" options={{ headerShown: false }} />
|
|
<Stack.Screen name="login" options={{ title: "Sign in", headerShown: false }} />
|
|
<Stack.Screen name="otp" options={{ title: "Verify OTP", headerShown: false }} />
|
|
<Stack.Screen name="register" options={{ title: "Create account", headerShown: false }} />
|
|
<Stack.Screen name="reports/index" options={{ title: "Reports" }} />
|
|
<Stack.Screen name="documents/index" options={{ title: "Documents" }} />
|
|
<Stack.Screen name="settings" options={{ title: "Settings" }} />
|
|
<Stack.Screen name="profile" options={{ headerShown: false }} />
|
|
<Stack.Screen name="edit-profile" options={{ headerShown: false }} />
|
|
<Stack.Screen name="user/create" options={{ headerShown: false }} />
|
|
</Stack>
|
|
<SessionHeartbeat />
|
|
<PortalHost />
|
|
<Toast />
|
|
</RNView>
|
|
</SirouRouterProvider>
|
|
</SafeAreaProvider>
|
|
</GestureHandlerRootView>
|
|
</ThemeProvider>
|
|
</NavigationIndependentTree>
|
|
);
|
|
}
|