5 Commits

21 changed files with 625 additions and 159 deletions

View File

@@ -2,6 +2,7 @@ pub mod tag {
/// Health tag constant
pub const HEALTH_TAG: &str = "Health";
pub const AUTH_TAG: &str = "Authentication";
pub const USER_TAG: &str = "User";
}
#[derive(utoipa::OpenApi)]
@@ -11,16 +12,21 @@ pub mod tag {
// Authentication paths
crate::routes::api::auth::login::login,
crate::routes::api::auth::init_admin::init_admin,
// User management paths
crate::routes::api::restricted::user::me::get_user_info,
),
components(
schemas(crate::routes::api::health::info::HealthInfo),
// Authentication schemas
schemas(crate::routes::api::auth::login::LoginRequest),
schemas(crate::routes::api::auth::init_admin::AdminInitRequest),
// User management schemas
schemas(crate::routes::api::restricted::user::me::UserInfo),
),
tags(
(name = tag::HEALTH_TAG, description = "Health information API"),
(name = tag::AUTH_TAG, description = "Authentication API")
(name = tag::AUTH_TAG, description = "Authentication API"),
(name = tag::USER_TAG, description = "User management API")
)
)]
pub struct ApiDoc;

View File

@@ -1,3 +1,5 @@
pub mod user;
use std::sync::Arc;
use axum::Router;
@@ -6,8 +8,7 @@ use crate::{middlewares::require_auth::require_auth, routes::AppState};
pub fn get_restricted_router(state: Arc<AppState>) -> Router {
Router::new()
//
//
.nest("/user", user::get_user_router(state.clone()))
.layer(axum::middleware::from_fn_with_state(
state.clone(),
require_auth,

View File

@@ -0,0 +1,13 @@
pub mod me;
use std::sync::Arc;
use axum::Router;
use crate::routes::AppState;
pub fn get_user_router(state: Arc<AppState>) -> Router {
Router::new()
.route("/me", axum::routing::get(me::get_user_info))
.with_state(state)
}

View File

@@ -0,0 +1,64 @@
use std::sync::Arc;
use axum::{
Extension, Json,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
use tracing::error;
use crate::{
middlewares::request_info::RequestInfo,
routes::{AppState, api::openapi::tag::USER_TAG},
};
/// System health information
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserInfo {
/// User ID
pub id: uuid::Uuid,
/// Username
pub username: String,
}
/// Get current user information
///
/// Returns the information of the currently authenticated user.
#[utoipa::path(
get,
path = "/api/user/me",
responses(
(status = 200, description = "User information retrieved successfully", body = UserInfo),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
tag = USER_TAG,
)]
pub async fn get_user_info(
State(app_state): State<Arc<AppState>>,
request_info: Extension<Arc<RequestInfo>>,
) -> Response {
let user_id = match request_info.user_id {
Some(id) => id,
None => {
error!("User ID not found in request info");
return (StatusCode::UNAUTHORIZED).into_response();
}
};
match app_state.service.user.get_user_by_id(user_id, None).await {
Ok(user) => {
let user_info = UserInfo {
id: user.id,
username: user.username,
};
(StatusCode::OK, Json(user_info)).into_response()
}
Err(err) => {
error!("Error fetching user info: {}", err);
(StatusCode::INTERNAL_SERVER_ERROR).into_response()
}
}
}

View File

@@ -105,6 +105,34 @@
}
}
}
},
"/api/user/me": {
"get": {
"tags": [
"User"
],
"summary": "Get current user information",
"description": "Returns the information of the currently authenticated user.",
"operationId": "get_user_info",
"responses": {
"200": {
"description": "User information retrieved successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserInfo"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"500": {
"description": "Internal server error"
}
}
}
}
},
"components": {
@@ -183,6 +211,25 @@
"type": "string"
}
}
},
"UserInfo": {
"type": "object",
"description": "System health information",
"required": [
"id",
"username"
],
"properties": {
"id": {
"type": "string",
"format": "uuid",
"description": "User ID"
},
"username": {
"type": "string",
"description": "Username"
}
}
}
}
},
@@ -194,6 +241,10 @@
{
"name": "Authentication",
"description": "Authentication API"
},
{
"name": "User",
"description": "User management API"
}
]
}

View File

@@ -0,0 +1 @@
/* intentionally empty: used to stub react-toastify CSS in production builds */

View File

@@ -9,6 +9,7 @@ export namespace Schemas {
version: string;
};
export type LoginRequest = { password: string; username: string };
export type UserInfo = { id: string; username: string };
// </Schemas>
}
@@ -41,6 +42,13 @@ export namespace Endpoints {
parameters: never;
responses: { 200: Schemas.HealthInfo; 404: unknown };
};
export type get_Get_user_info = {
method: "GET";
path: "/api/user/me";
requestFormat: "json";
parameters: never;
responses: { 200: Schemas.UserInfo; 401: unknown; 500: unknown };
};
// </Endpoints>
}
@@ -53,6 +61,7 @@ export type EndpointByMethod = {
};
get: {
"/api/health/info": Endpoints.get_Get_health_info;
"/api/user/me": Endpoints.get_Get_user_info;
};
};

View File

@@ -1,8 +1,10 @@
import { Text } from '@radix-ui/themes';
import { AxiosError } from 'axios';
import { useLocation, useNavigate } from 'react-router';
import { toast } from 'react-toastify';
import { SearchParamKeys } from '../lib/constants';
import { useQueryMessage } from './useQueryMessage';
import { QueryMessageCode, QueryMessageType } from '../lib/QueryMessages';
import { useCallback } from 'react';
import { displayForbiddenErrorToast, displayNetworkErrorToast, displayUnexpectedErrorToast } from '../lib/toasts';
export enum ResponseErrorToastId {
NetworkError = 'network-error',
@@ -19,26 +21,18 @@ export type DefaultResponseErrorHandlerOptions = {
* @param err error value
* @returns {boolean} true if the error was handled, false otherwise
*/
const defaultResponseErrorHandler =
(navigate: ReturnType<typeof useNavigate>, location: ReturnType<typeof useLocation>) =>
export function useResponseErrorHandler(): {
defaultResponseErrorHandler: typeof defaultResponseErrorHandler;
} {
const navigate = useNavigate();
const location = useLocation();
const { toSearchParamQueryMessage } = useQueryMessage();
const defaultResponseErrorHandler = useCallback(
(err: unknown, options?: DefaultResponseErrorHandlerOptions): boolean => {
if (!(err instanceof AxiosError) && !options?.disableHandleUnexpectedErrors) {
toast.error(
<div>
<Text weight="bold">Unexpected Error:</Text>
<br /> An unexpected error occurred. Please try again later.
</div>,
{
position: 'top-center',
autoClose: false,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: false,
progress: undefined,
theme: 'colored',
}
);
displayUnexpectedErrorToast();
return true;
}
@@ -50,23 +44,7 @@ const defaultResponseErrorHandler =
}
if (err.message === 'Network Error') {
toast.error(
<div>
<Text weight="bold">Network Error:</Text>
<br /> Unable to reach the server. Please check your internet connection and try again.
</div>,
{
toastId: ResponseErrorToastId.NetworkError,
position: 'top-center',
autoClose: false,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: false,
progress: undefined,
theme: 'colored',
}
);
displayNetworkErrorToast();
return true;
}
@@ -76,36 +54,20 @@ const defaultResponseErrorHandler =
const currentPath = location.pathname + location.search;
const searchParam = new URLSearchParams();
searchParam.set(SearchParamKeys.Redirect, currentPath);
searchParam.set(SearchParamKeys.Message, 'Session expired, please log in again');
searchParam.set(SearchParamKeys.Message, toSearchParamQueryMessage(QueryMessageCode.SessionExpired, QueryMessageType.Info));
navigate(`/login?${searchParam.toString()}`);
return true;
}
if (err.status === 403) {
toast.error(
<div>
<Text weight="bold">Forbidden:</Text>
<br /> You do not have permission to perform this action.
</div>,
{
position: 'top-center',
autoClose: 5000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: false,
progress: undefined,
theme: 'colored',
}
);
displayForbiddenErrorToast();
return true;
}
return false;
};
},
[location, navigate, toSearchParamQueryMessage]
);
export function useResponseErrorHandler() {
const navigate = useNavigate();
const location = useLocation();
return { defaultResponseErrorHandler: defaultResponseErrorHandler(navigate, location) };
return { defaultResponseErrorHandler };
}

View File

@@ -0,0 +1,48 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router';
import { useAuth } from '../providers/AuthProvider';
import { useApi } from '../providers/ApiProvider';
import { useQuery } from '@tanstack/react-query';
import { useResponseErrorHandler } from './ResponseHelper';
export type EnsureLoggedInResult = {
checking: boolean;
loggedIn: boolean;
};
export function useEnsureLoggedIn(): EnsureLoggedInResult {
const { user, setUser } = useAuth();
const navigate = useNavigate();
const { tanstackApiClient } = useApi();
const { defaultResponseErrorHandler } = useResponseErrorHandler();
const { queryOptions: currentUserQuery } = tanstackApiClient.get('/api/user/me');
const { isFetched, isPending } = useQuery({
...currentUserQuery,
queryFn: async (...args) => {
try {
const data = await currentUserQuery.queryFn!(...args);
setUser({
id: data.id,
name: data.username,
});
return data;
} catch (error) {
if (defaultResponseErrorHandler(error)) return {} as never;
throw error;
}
},
});
useEffect(() => {
if (user) {
navigate('/', { replace: true });
return;
}
}, [user, setUser, navigate]);
return {
checking: isPending,
loggedIn: isFetched && !!user,
};
}

View File

@@ -0,0 +1,111 @@
import { useCallback, useEffect, useRef, type ReactNode } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { toast } from 'react-toastify/unstyled';
import { SearchParamKeys } from '../lib/constants';
import { CODE_TO_MESSAGE_MAP, QueryMessageCode, QueryMessageType } from '../lib/QueryMessages';
type QueryMessageString = `${QueryMessageCode}__${QueryMessageType}`;
export type QueryMessage = {
type: QueryMessageType;
code: QueryMessageCode;
message: ReactNode;
};
export type UseQueryMessageOptions = {
displayMessages?: boolean;
};
export type UseQueryMessageReturn = {
setQueryMessage: (messageCode: QueryMessageCode, messageType: QueryMessageType) => void;
clearQueryMessage: () => void;
toSearchParamQueryMessage: (message: QueryMessageCode, type: QueryMessageType) => QueryMessageString;
};
export function useQueryMessage(
{ displayMessages }: UseQueryMessageOptions = {
displayMessages: true,
}
): UseQueryMessageReturn {
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const messageStr = useRef<QueryMessageString | null>(null);
useEffect(() => {
// Reset messageStr when location changes to allow re-displaying the same message on navigation
messageStr.current = null;
}, [location.pathname]);
useEffect(() => {
const queryMessageStr = searchParams.get(SearchParamKeys.Message);
if (!(queryMessageStr && queryMessageStr !== messageStr.current)) return;
const [queryMessage, queryMessageString] = toQueryMessage(queryMessageStr) ?? [null, null];
if (!queryMessage) return;
messageStr.current = queryMessageString;
if (displayMessages) {
toast[queryMessage.type](queryMessage.code, {
position: 'top-center',
autoClose: 5000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: false,
progress: undefined,
theme: 'colored',
toastId: 'login-route-info-message',
});
}
}, [displayMessages, searchParams]);
const setQueryMessage = useCallback(
(messageCode: QueryMessageCode, messageType: QueryMessageType) => {
const queryMessageString: QueryMessageString = `${messageCode}__${messageType}`;
messageStr.current = queryMessageString;
setSearchParams((prev) => {
prev.set(SearchParamKeys.Message, queryMessageString);
return prev;
});
},
[setSearchParams]
);
const clearQueryMessage = useCallback(() => {
messageStr.current = null;
setSearchParams((prev) => {
prev.delete(SearchParamKeys.Message);
return prev;
});
}, [setSearchParams]);
const toSearchParamQueryMessage = useCallback((message: QueryMessageCode, type: QueryMessageType): QueryMessageString => {
return `${message}__${type}`;
}, []);
return {
setQueryMessage,
clearQueryMessage,
toSearchParamQueryMessage,
};
}
function isValidQueryMessageCode(code: string): code is QueryMessageCode {
return Object.values(QueryMessageCode).includes(code as QueryMessageCode);
}
function isValidQueryMessageType(type: string): type is QueryMessageType {
return Object.values(QueryMessageType).includes(type as QueryMessageType);
}
function toQueryMessage(value: string): [QueryMessage, QueryMessageString] | null {
const [code, type] = value.split('__');
if (!isValidQueryMessageCode(code) || !isValidQueryMessageType(type)) return null;
return [
{
code: code,
type: type,
message: CODE_TO_MESSAGE_MAP[code],
},
`${code}__${type}`,
];
}

View File

@@ -0,0 +1,20 @@
import type { ReactNode } from 'react';
export enum QueryMessageType {
Info = 'info',
Success = 'success',
Warning = 'warning',
Error = 'error',
}
export enum QueryMessageCode {
SessionExpired = 'SESSION_EXPIRED',
InitializationRequired = 'INITIALIZATION_REQUIRED',
InitializationSuccessful = 'INITIALIZATION_SUCCESSFUL',
}
export const CODE_TO_MESSAGE_MAP: Record<QueryMessageCode, ReactNode> = {
[QueryMessageCode.SessionExpired]: 'Your session has expired. Please log in again.',
[QueryMessageCode.InitializationRequired]: 'The application requires initialization. Please follow the setup instructions.',
[QueryMessageCode.InitializationSuccessful]: 'Initialization successful. Please log in.',
} as const;

View File

@@ -0,0 +1,64 @@
import { toast, type ToastOptions } from 'react-toastify/unstyled';
import { Text } from '@radix-ui/themes';
import { ResponseErrorToastId } from '../hooks/ResponseHelper';
export const displayUnexpectedErrorToast = (options: ToastOptions = {}) => {
toast.error(
<div>
<Text weight="bold">Unexpected Error:</Text>
<br /> An unexpected error occurred. Please try again later.
</div>,
{
position: 'top-center',
autoClose: false,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: false,
progress: undefined,
theme: 'colored',
...options,
}
);
};
export const displayNetworkErrorToast = (options: ToastOptions = {}) => {
toast.error(
<div>
<Text weight="bold">Network Error:</Text>
<br /> Unable to reach the server. Please check your internet connection and try again.
</div>,
{
toastId: ResponseErrorToastId.NetworkError,
position: 'top-center',
autoClose: false,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: false,
progress: undefined,
theme: 'colored',
...options,
}
);
};
export const displayForbiddenErrorToast = (options: ToastOptions = {}) => {
toast.error(
<div>
<Text weight="bold">Forbidden:</Text>
<br /> You do not have permission to perform this action.
</div>,
{
position: 'top-center',
autoClose: 5000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: false,
progress: undefined,
theme: 'colored',
...options,
}
);
};

View File

@@ -0,0 +1,56 @@
import { useNavigate } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { createContext, use, type PropsWithChildren } from 'react';
import { useApi } from './ApiProvider';
import { useResponseErrorHandler } from '../hooks/ResponseHelper';
import type { Schemas } from '../generated/api-client/api-client';
export type HealthStatus = Schemas.HealthInfo;
export type ApiHealthProviderProps = PropsWithChildren<object>;
export type ApiHealthContextType = {
healthStatus: HealthStatus | undefined;
};
const ApiHealthContext = createContext<ApiHealthContextType | null>(null);
export const ApiHealthProvider: React.FC<ApiHealthProviderProps> = ({ children }) => {
const navigate = useNavigate();
const { tanstackApiClient } = useApi();
const { defaultResponseErrorHandler } = useResponseErrorHandler();
const { queryOptions: healthInfoQuery } = tanstackApiClient.get('/api/health/info');
const { data } = useQuery({
...healthInfoQuery,
queryFn: async (...args) => {
try {
const data = await healthInfoQuery.queryFn!(...args);
if (!data.is_initialized) {
navigate('/init');
}
return data;
} catch (error) {
if (defaultResponseErrorHandler(error)) return {} as never;
throw error;
}
},
});
return (
<ApiHealthContext
value={{
healthStatus: data,
}}
>
{children}
</ApiHealthContext>
);
};
export const useApiHealth = (): ApiHealthContextType => {
const context = use(ApiHealthContext);
if (!context) {
throw new Error('useApiHealth must be used within an ApiHealthProvider');
}
return context;
};

View File

@@ -2,7 +2,6 @@ import { createContext, use, type PropsWithChildren } from 'react';
import { createTanstackApi, createApi } from '../lib/api';
import axios from 'axios';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useNavigate } from 'react-router';
type ApiProviderProps = PropsWithChildren<object>;
type ApiContextType = {

View File

@@ -0,0 +1,47 @@
import { createContext, use, useCallback, useState, type PropsWithChildren } from 'react';
export type User = {
id: string;
name: string;
};
export type AuthProviderProps = PropsWithChildren<object>;
export type AuthContextType = {
setUser: (user: User) => void;
logOut: () => void;
user: User | null;
};
const AuthContext = createContext<AuthContextType | null>(null);
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [user, setUserState] = useState<User | null>(null);
const setUser = useCallback((user: User) => {
setUserState(user);
}, []);
const logout = useCallback(() => {
setUserState(null);
}, []);
return (
<AuthContext
value={{
user: user,
logOut: logout,
setUser: setUser,
}}
>
{children}
</AuthContext>
);
};
export function useAuth() {
const context = use(AuthContext);
if (!context) {
throw new Error('useAuth must be used within a AuthProvider');
}
return context;
}

View File

@@ -2,10 +2,17 @@ import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration }
import type { Route } from './+types/root';
import '@radix-ui/themes/styles.css';
import './app.css';
// start: react-toastify special import
// ! MUST use unstyled version for dev server build, styled version for production build is handled in vite.config.ts
import { ToastContainer } from 'react-toastify/unstyled';
import 'react-toastify/ReactToastify.css';
// end: react-toastify special import
import AppTheme from './components/theme';
import { ApiProvider } from './providers/ApiProvider';
import { LayoutProvider } from './providers/LayoutProvider';
import { Tooltip } from 'radix-ui';
import { AuthProvider } from './providers/AuthProvider';
import { ApiHealthProvider } from './providers/ApiHealthProvider';
export const links: Route.LinksFunction = () => [];
@@ -29,15 +36,23 @@ export function Layout({ children }: { children: React.ReactNode }) {
export default function App() {
return (
<>
<AppTheme>
<ApiProvider>
<Tooltip.Provider delayDuration={250}>
<LayoutProvider>
<ApiHealthProvider>
<AuthProvider>
<Outlet />
</AuthProvider>
</ApiHealthProvider>
</LayoutProvider>
</Tooltip.Provider>
</ApiProvider>
</AppTheme>
<ToastContainer />
</>
);
}

View File

@@ -1,15 +1,15 @@
import { Box, Container, Flex, Heading } from '@radix-ui/themes';
import { useMutation } from '@tanstack/react-query';
import { useLocation, useNavigate } from 'react-router';
import { Slide, toast, ToastContainer } from 'react-toastify';
import { toast } from 'react-toastify/unstyled';
import * as v from 'valibot';
import { useResponseErrorHandler } from '../../hooks/ResponseHelper';
import { useApi } from '../../providers/ApiProvider';
import { formHook } from '../../providers/FormProvider';
import type { Route } from './+types/login';
import { SearchParamKeys } from '../../lib/constants';
import { useEffect, useState } from 'react';
import { AxiosError } from 'axios';
import { useQueryMessage } from '../../hooks/useQueryMessage';
const loginFormSchema = v.object({
username: v.pipe(v.string(), v.trim(), v.minLength(1, 'Username is required')),
@@ -27,7 +27,7 @@ export default function LoginRoute() {
const location = useLocation();
const { tanstackApiClient } = useApi();
const { defaultResponseErrorHandler } = useResponseErrorHandler();
const [previousSearchParamMessage, setPreviousSearchParamMessage] = useState<string>('');
useQueryMessage();
const { mutateAsync: login, isPending } = useMutation({
...tanstackApiClient.mutation('post', '/api/auth/login').mutationOptions,
@@ -75,25 +75,6 @@ export default function LoginRoute() {
},
});
useEffect(() => {
const searchParams = new URLSearchParams(location.search);
const message = searchParams.get(SearchParamKeys.Message);
if (message && message !== previousSearchParamMessage) {
setPreviousSearchParamMessage(message);
toast.info(message, {
position: 'top-center',
autoClose: 5000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: false,
progress: undefined,
theme: 'colored',
toastId: 'login-route-info-message',
});
}
}, [location.search]);
return (
<>
<Flex align="center" justify="center" style={{ minHeight: 'calc(100vh - 64px)' }}>
@@ -167,17 +148,6 @@ export default function LoginRoute() {
</Box>
</Container>
</Flex>
<ToastContainer
position="top-center"
autoClose={false}
newestOnTop={false}
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable={false}
theme="colored"
transition={Slide}
/>
</>
);
}

View File

@@ -2,6 +2,7 @@ import { Box, Button, Card, Flex, Grid, Heading, Text } from '@radix-ui/themes';
import type { Route } from './+types/home';
import TablePlaceholder from '../components/home/TablePlaceholder';
import { useLayout } from '../providers/LayoutProvider';
import { useEnsureLoggedIn } from '../hooks/ensureLoggedIn';
// eslint-disable-next-line no-empty-pattern
export function meta({}: Route.MetaArgs) {
@@ -9,6 +10,7 @@ export function meta({}: Route.MetaArgs) {
}
export default function ProxyHostDemo() {
useEnsureLoggedIn();
const { activeTab } = useLayout();
return (
<Box>

View File

@@ -1,13 +1,15 @@
import { Box, Container, Flex, Heading, Text } from '@radix-ui/themes';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router';
import { Slide, toast, ToastContainer } from 'react-toastify';
import { toast } from 'react-toastify/unstyled';
import * as v from 'valibot';
import { useResponseErrorHandler } from '../hooks/ResponseHelper';
import { useApi } from '../providers/ApiProvider';
import { formHook } from '../providers/FormProvider';
import { TooltipContentContainer } from '../components/info';
import { SearchParamKeys } from '../lib/constants';
import { useQueryMessage } from '../hooks/useQueryMessage';
import { QueryMessageCode, QueryMessageType } from '../lib/QueryMessages';
const initFormSchema = v.object({
username: v.pipe(v.string(), v.trim(), v.minLength(1, 'Username is required')),
@@ -19,12 +21,13 @@ export default function InitRoute() {
const navigate = useNavigate();
const { tanstackApiClient } = useApi();
const { defaultResponseErrorHandler } = useResponseErrorHandler();
const { toSearchParamQueryMessage } = useQueryMessage();
const { mutateAsync: initAdmin, isPending } = useMutation({
...tanstackApiClient.mutation('post', '/api/auth/init_admin').mutationOptions,
onSuccess: async () => {
const searchParams = new URLSearchParams();
searchParams.set(SearchParamKeys.Message, 'Initialization successful. Please log in.');
searchParams.set(SearchParamKeys.Message, toSearchParamQueryMessage(QueryMessageCode.InitializationSuccessful, QueryMessageType.Success));
navigate(`/login?${searchParams.toString()}`);
},
onError: (error) => {
@@ -40,7 +43,7 @@ export default function InitRoute() {
try {
const data = await healthInfoQuery.queryFn!(...args);
if (data.is_initialized) {
navigate('/');
navigate('/login', { replace: true });
return data;
}
return data;
@@ -153,18 +156,6 @@ export default function InitRoute() {
</Box>
</Container>
</Flex>
<ToastContainer
position="top-center"
autoClose={false}
newestOnTop={false}
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable={false}
theme="colored"
transition={Slide}
/>
</>
);
}

View File

@@ -1,6 +1,8 @@
import js from '@eslint/js';
import globals from 'globals';
import tseslint from 'typescript-eslint';
import pluginReact from 'eslint-plugin-react';
import pluginReactHooks from 'eslint-plugin-react-hooks';
export default tseslint.config(
{
@@ -22,5 +24,19 @@ export default tseslint.config(
},
},
rules: {},
},
{
...pluginReact.configs.flat.recommended, // Enables core React rules
...pluginReactHooks.configs.flat.recommended, // Enables React Hooks rules
languageOptions: {
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
globals: {
...globals.browser,
},
},
}
);

View File

@@ -5,7 +5,10 @@ import tsconfigPaths from 'vite-tsconfig-paths';
// @ts-expect-error vite-plugin-eslint has no types
import eslint from 'vite-plugin-eslint';
export default defineConfig({
export default defineConfig(({ command }) => {
const isBuild = command === 'build';
return {
plugins: [
tailwindcss(),
reactRouter(),
@@ -14,5 +17,22 @@ export default defineConfig({
failOnError: false,
}),
],
resolve: {
alias: isBuild
? [
{
// replace unstyled import with styled for SPA build
find: 'react-toastify/unstyled',
replacement: 'react-toastify',
},
{
// point to the empty CSS file to stub out the import during build, SPA build does not require extra CSS imports
find: 'react-toastify/ReactToastify.css',
replacement: '~/empty-toastify.css',
},
]
: [],
},
appType: 'spa',
};
});