Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,6 @@ storybook-static/
*.njsproj
*.sln
*.sw?

# Ai
.claude/
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"@package/ui": "workspace:*",
"@tanstack/react-form": "1.23.7",
"@tanstack/react-query": "5.83.0",
"@tanstack/react-router": "^1.103.2",
"clsx": "2.1.1",
"immer": "10.1.1",
"jotai": "2.13.1",
Expand All @@ -46,6 +47,7 @@
"@package/storybook": "workspace:*",
"@storybook/react": "catalog:storybook",
"@tailwindcss/vite": "catalog:tailwind",
"@tanstack/router-plugin": "^1.103.0",
"@vitejs/plugin-basic-ssl": "catalog:vite",
"@vitejs/plugin-react": "catalog:vite",
"babel-plugin-react-compiler": "catalog:react19",
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/classes/app-session/UseAppSessionContent.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { useMemo } from "react";
import { useFetchPersonalProfileQuery } from "@package/api";
import { useNavigate } from "react-router-dom";
import { useNavigate } from "@tanstack/react-router";
import type { IAppSessionContent } from "./IAppSessionContent";
import { useAuth } from "~/core/auth/UseAuth";

export function useAppSessionContent(aboutRoute: string = "/about"): IAppSessionContent {
export function useAppSessionContent(aboutRoute: string = "/version"): IAppSessionContent {
const navigate = useNavigate();
const { logout } = useAuth();
const { data: profileInfo } = useFetchPersonalProfileQuery();
Expand All @@ -30,7 +30,7 @@ export function useAppSessionContent(aboutRoute: string = "/about"): IAppSession
}

function handleOnAbout(): void {
navigate(aboutRoute);
navigate({ to: aboutRoute as "/version" });
Copy link

Copilot AI Oct 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type assertion as '/version' is unsafe as aboutRoute could be any string value. Consider using proper type checking or constraining the aboutRoute parameter to only accept valid route paths.

Copilot uses AI. Check for mistakes.
}

return {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/classes/auth/AuthStatus.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export type AuthStatus = "idle" | "authenticating" | "authenticated";
export type AuthStatus = "idle" | "authenticating" | "authenticated" | "unauthenticated";
11 changes: 7 additions & 4 deletions apps/web/src/core/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { type ReactElement, useEffect } from "react";
import { AppProviders } from "./AppProviders";
import { AppTheme } from "./AppTheme";
import { AppRoutes } from "./routes/AppRoutes";
import { RouteLoading } from "./routes/logic/RouteLoading";

export function App(): ReactElement {
function handlePreloadError(): void {
Expand All @@ -18,10 +19,12 @@ export function App(): ReactElement {
}, []);

return (
<AppProviders>
<AppTheme>
<AppRoutes />
</AppTheme>
<AppProviders loadingFallback={<RouteLoading />}>
{() => (
<AppTheme>
<AppRoutes />
</AppTheme>
)}
</AppProviders>
);
}
12 changes: 8 additions & 4 deletions apps/web/src/core/AppProviders.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import type { ReactElement } from "react";
import type { ReactElement, ReactNode } from "react";
import { HeroUIProvider } from "@heroui/react";
import { ToastProvider } from "@heroui/toast";
import { validateCrypto } from "@package/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AppLocales } from "./AppLocales";
import { AuthInitializer } from "./auth/AuthInitializer";
import { AuthProvider } from "./auth/AuthProvider";
import { PwaProviderModule } from "~/components/modules/pwa-provider-module/PwaProviderModule";

const queryClient = new QueryClient();

export interface AppProvidersProps {
children: ReactElement;
children: (authStatus: "authenticated" | "unauthenticated") => ReactNode;
loadingFallback?: ReactNode;
}

export function AppProviders({ children }: AppProvidersProps): ReactElement {
export function AppProviders({ children, loadingFallback }: AppProvidersProps): ReactElement {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
typeof window !== "undefined" && validateCrypto();
return (
Expand All @@ -22,7 +24,9 @@ export function AppProviders({ children }: AppProvidersProps): ReactElement {
<AppLocales>
<PwaProviderModule>
<QueryClientProvider client={queryClient}>
<AuthProvider>{children}</AuthProvider>
<AuthInitializer fallback={loadingFallback}>
{(authStatus) => <AuthProvider initialAuthStatus={authStatus}>{children(authStatus)}</AuthProvider>}
</AuthInitializer>
</QueryClientProvider>
</PwaProviderModule>
</AppLocales>
Expand Down
40 changes: 40 additions & 0 deletions apps/web/src/core/auth/AuthInitializer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { ReactElement, ReactNode } from "react";
import { useState, useEffect } from "react";
import { usePostRefreshTokenMutate } from "@package/api";

export interface AuthInitializerProps {
children: (authStatus: "authenticated" | "unauthenticated") => ReactNode;
fallback?: ReactNode;
}

/**
* Component that initializes authentication by attempting to refresh the token.
* Only renders children once auth status has been determined.
*/
export function AuthInitializer({ children, fallback = null }: AuthInitializerProps): ReactElement {
const [authStatus, setAuthStatus] = useState<"authenticated" | "unauthenticated" | null>(null);
const { mutateAsync: refreshToken } = usePostRefreshTokenMutate();

useEffect(() => {
async function initializeAuth(): Promise<void> {
try {
// Attempt to refresh token on app load to check if user is logged in
await refreshToken();
setAuthStatus("authenticated");
} catch {
// If refresh fails, user is not authenticated
setAuthStatus("unauthenticated");
}
}

void initializeAuth();
}, [refreshToken]);

// Show fallback while determining auth status
if (authStatus === null) {
return <>{fallback}</>;
}

// Once auth status is determined, render children with the status
return <>{children(authStatus)}</>;
}
21 changes: 5 additions & 16 deletions apps/web/src/core/auth/AuthProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,22 @@
import type { ReactNode, ReactElement } from "react";
import { useReducer } from "react";
import { usePostRefreshTokenMutate } from "@package/api";
import { useRunOnce } from "@package/react";
import { AuthContext } from "./AuthContext";
import { AuthReducer } from "./AuthReducer";
import type { AuthState } from "~/classes/auth/AuthState";

export interface AuthProviderProps {
children: ReactNode;
initialAuthStatus?: AuthState["status"];
}

const initialState: AuthState = {
status: "idle",
};

export function AuthProvider({ children }: AuthProviderProps): ReactElement {
const [state, dispatch] = useReducer(AuthReducer, initialState);
const { mutateAsync: refreshToken } = usePostRefreshTokenMutate();

useRunOnce({
func: async () => {
try {
// Run once on app load to check if the user is logged in
await refreshToken();
dispatch({ type: "auth/login" });
} catch {
dispatch({ type: "auth/logout" });
}
},
export function AuthProvider({ children, initialAuthStatus = "idle" }: AuthProviderProps): ReactElement {
const [state, dispatch] = useReducer(AuthReducer, {
...initialState,
status: initialAuthStatus,
});

return (
Expand Down
13 changes: 10 additions & 3 deletions apps/web/src/core/auth/AuthReducer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { produce } from "immer";
import type { AuthState } from "~/classes/auth/AuthState";
import type { AuthStatus } from "~/classes/auth/AuthStatus";

type AuthActions = { type: "auth/loading" } | { type: "auth/login" } | { type: "auth/logout" };
type AuthActions =
| { type: "auth/loading" }
| { type: "auth/login" }
| { type: "auth/logout" }
| { type: "auth/initialized"; authenticated: boolean };

export interface AuthReducerProps extends AuthState {}

Expand All @@ -11,13 +15,16 @@ export function AuthReducer(baseState: AuthReducerProps, action: AuthActions): A
return produce<AuthReducerProps>(baseState, (draft) => {
switch (type) {
case "auth/loading":
draft.status = "idle";
draft.status = "authenticating";
break;
case "auth/login":
draft.status = "authenticated";
break;
case "auth/logout":
draft.status = "authenticating";
draft.status = "unauthenticated";
break;
case "auth/initialized":
draft.status = action.authenticated ? "authenticated" : "unauthenticated";
break;
}
});
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/core/auth/UseAuth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useContext } from "react";
import type { LoginData } from "@package/api";
import { usePostLoginMutate, usePostLogoutMutate } from "@package/api";
import { useNavigate, useRouter } from "@tanstack/react-router";
import { AuthContext } from "./AuthContext";
import type { AuthStatus } from "./AuthReducer";

Expand All @@ -11,6 +12,8 @@ export function useAuth(): {
loginLoading: boolean;
logoutLoading: boolean;
} {
const router = useRouter();
const navigate = useNavigate();
const {
state: { status },
dispatch,
Expand All @@ -30,6 +33,9 @@ export function useAuth(): {
dispatch({
type: "auth/logout",
});
router.invalidate().finally(() => {
navigate({ to: "/" });
});
}

return {
Expand Down
57 changes: 31 additions & 26 deletions apps/web/src/core/routes/AppRoutes.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,38 @@
import type { ReactElement } from "react";
import type { QueryClient } from "@tanstack/react-query";
import { useQueryClient } from "@tanstack/react-query";
import { RouterProvider, Route, createHashRouter, createRoutesFromElements, Outlet } from "react-router-dom";
import { PrivateRouteLogic } from "./logic/PrivateRouteLogic";
import { PublicRouteLogic } from "./logic/PublicRouteLogic";
import { PrivateRoutes } from "./PrivateRoutes";
import { PublicRoutes } from "./PublicRoutes";
import { RouterProvider, createHashHistory, createRouter } from "@tanstack/react-router";
import { routeTree } from "../../routeTree.gen";
import { useAuth } from "../auth/UseAuth";
import type { AuthStatus } from "~/classes/auth/AuthStatus";

export interface RouterContext {
queryClient: QueryClient;
authStatus: AuthStatus;
}

const hashHistory = createHashHistory();

const router = createRouter({
routeTree,
history: hashHistory,
context: {
queryClient: undefined!,
authStatus: undefined!,
},
defaultPreload: "intent",
defaultPreloadStaleTime: 0,
});

declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}

export function AppRoutes(): ReactElement {
const queryClient = useQueryClient();
const { status } = useAuth();

return (
<RouterProvider
router={createHashRouter(
createRoutesFromElements(
<Route path="/" element={<Outlet />}>
<Route element={<PublicRouteLogic />}>
{PublicRoutes(queryClient).map((route) => (
<Route key={route.path} {...route} />
))}
</Route>
<Route element={<PrivateRouteLogic />}>
{PrivateRoutes(queryClient).map((route) => (
<Route key={route.path} {...route} />
))}
</Route>
<Route path="*" lazy={() => import("../../pages/not-found/NotFoundRoute")} />
</Route>
)
)}
/>
);
return <RouterProvider router={router} context={{ queryClient, authStatus: status }} />;
}
6 changes: 3 additions & 3 deletions apps/web/src/pages/about/UseAboutPage.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { useNavigate } from "react-router-dom";
import { useRouter } from "@tanstack/react-router";
import type { AboutViewProps } from "~/components/views/about/AboutView";
import { useAppInfo } from "~/core/config/UseAppInfo";

export function useAboutPage(serverVersion: string): AboutViewProps {
const { appName } = useAppInfo();
const navigate = useNavigate();
const router = useRouter();

function handleOnBack(): void {
navigate(-1);
router.history.back();
}

return { appVersion: import.meta.env.VITE_APP_VERSION, serverVersion, appName, onBack: handleOnBack };
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/pages/about/modal/UseAboutModalPage.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { useNavigate } from "react-router-dom";
import { useRouter } from "@tanstack/react-router";
import { useAboutPage } from "../UseAboutPage";
import type { AboutModalProps } from "~/components/feedback/about-modal/AboutModal";

export function useAboutModalPage(serverVersion: string): AboutModalProps {
const navigate = useNavigate();
const router = useRouter();
const aboutProps = useAboutPage(serverVersion);

function handleOnClose(): void {
navigate(-1);
router.history.back();
}

return {
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/pages/forgot-password/UseForgotPasswordPage.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { addToast } from "@heroui/react";
import { usePostForgotPasswordMutate } from "@package/api";
import { useNavigate } from "@tanstack/react-router";
import { useIntl } from "react-intl";
import { useNavigate } from "react-router-dom";
import type { FormForgotPassword } from "~/components/forms/forgot-password/ForgotPasswordForm";
import type { ForgotPasswordViewProps } from "~/components/views/forgot-password/ForgotPasswordView";
import { useAppInfo } from "~/core/config/UseAppInfo";
Expand All @@ -14,15 +14,15 @@ export function useForgotPasswordPage(): ForgotPasswordViewProps {

function handleOnBack(): void {
console.log("handleBack");
navigate("/login");
navigate({ to: "/login" });
}

async function handleOnSubmit(data: FormForgotPassword): Promise<void> {
const { email } = data;

try {
await submit(email);
navigate("/");
navigate({ to: "/" });
} catch {
addToast({
title: intl.formatMessage({
Expand Down
Loading