mirror of
https://github.com/Kingsrook/qqq-frontend-material-dashboard.git
synced 2025-07-18 05:10:45 +00:00
QQQ-32: added booleans, cleaned up error handling, fixed infinite loop on unauthorized login, removed all the login buttons, removed redundant qClient functions
This commit is contained in:
@ -13,7 +13,7 @@
|
|||||||
"@fullcalendar/interaction": "5.10.0",
|
"@fullcalendar/interaction": "5.10.0",
|
||||||
"@fullcalendar/react": "5.10.0",
|
"@fullcalendar/react": "5.10.0",
|
||||||
"@fullcalendar/timegrid": "5.10.0",
|
"@fullcalendar/timegrid": "5.10.0",
|
||||||
"@kingsrook/qqq-frontend-core": "1.0.6",
|
"@kingsrook/qqq-frontend-core": "1.0.7",
|
||||||
"@mui/icons-material": "5.4.1",
|
"@mui/icons-material": "5.4.1",
|
||||||
"@mui/material": "5.4.1",
|
"@mui/material": "5.4.1",
|
||||||
"@mui/styled-engine": "5.4.1",
|
"@mui/styled-engine": "5.4.1",
|
||||||
|
42
src/App.tsx
42
src/App.tsx
@ -38,9 +38,7 @@ import {useMaterialUIController, setMiniSidenav, setOpenConfigurator} from "cont
|
|||||||
// Images
|
// Images
|
||||||
import nfLogo from "assets/images/nutrifresh_one_icon_white.png";
|
import nfLogo from "assets/images/nutrifresh_one_icon_white.png";
|
||||||
import {Md5} from "ts-md5/dist/md5";
|
import {Md5} from "ts-md5/dist/md5";
|
||||||
import AuthenticationButton from "qqq/components/buttons/AuthenticationButton";
|
|
||||||
import {useCookies} from "react-cookie";
|
import {useCookies} from "react-cookie";
|
||||||
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
|
|
||||||
import EntityCreate from "./qqq/pages/entity-create";
|
import EntityCreate from "./qqq/pages/entity-create";
|
||||||
import EntityList from "./qqq/pages/entity-list";
|
import EntityList from "./qqq/pages/entity-list";
|
||||||
import EntityView from "./qqq/pages/entity-view";
|
import EntityView from "./qqq/pages/entity-view";
|
||||||
@ -49,7 +47,6 @@ import ProcessRun from "./qqq/pages/process-run";
|
|||||||
import MDAvatar from "./components/MDAvatar";
|
import MDAvatar from "./components/MDAvatar";
|
||||||
import ProfileOverview from "./layouts/pages/profile/profile-overview";
|
import ProfileOverview from "./layouts/pages/profile/profile-overview";
|
||||||
import Settings from "./layouts/pages/account/settings";
|
import Settings from "./layouts/pages/account/settings";
|
||||||
import SignInBasic from "./layouts/authentication/sign-in/basic";
|
|
||||||
import Analytics from "./layouts/dashboards/analytics";
|
import Analytics from "./layouts/dashboards/analytics";
|
||||||
import Sales from "./layouts/dashboards/sales";
|
import Sales from "./layouts/dashboards/sales";
|
||||||
import QClient from "./qqq/utils/QClient";
|
import QClient from "./qqq/utils/QClient";
|
||||||
@ -86,14 +83,14 @@ function getStaticRoutes()
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const SESSION_ID_COOKIE_NAME = "sessionId";
|
export const SESSION_ID_COOKIE_NAME = "sessionId";
|
||||||
LicenseInfo.setLicenseKey(process.env.REACT_APP_MATERIAL_UI_LICENSE_KEY);
|
LicenseInfo.setLicenseKey(process.env.REACT_APP_MATERIAL_UI_LICENSE_KEY);
|
||||||
|
|
||||||
export default function App()
|
export default function App()
|
||||||
{
|
{
|
||||||
const [, setCookie] = useCookies([SESSION_ID_COOKIE_NAME]);
|
const [, setCookie] = useCookies([SESSION_ID_COOKIE_NAME]);
|
||||||
const {
|
const {
|
||||||
user, getAccessTokenSilently, getIdTokenClaims, logout,
|
user, getAccessTokenSilently, getIdTokenClaims, logout, loginWithRedirect,
|
||||||
} = useAuth0();
|
} = useAuth0();
|
||||||
const [loadingToken, setLoadingToken] = useState(false);
|
const [loadingToken, setLoadingToken] = useState(false);
|
||||||
const [isFullyAuthenticated, setIsFullyAuthenticated] = useState(false);
|
const [isFullyAuthenticated, setIsFullyAuthenticated] = useState(false);
|
||||||
@ -110,7 +107,7 @@ export default function App()
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
console.log("Loading token...");
|
console.log("Loading token...");
|
||||||
const accessToken = await getAccessTokenSilently();
|
await getAccessTokenSilently();
|
||||||
const idToken = await getIdTokenClaims();
|
const idToken = await getIdTokenClaims();
|
||||||
setCookie(SESSION_ID_COOKIE_NAME, idToken.__raw, {path: "/"});
|
setCookie(SESSION_ID_COOKIE_NAME, idToken.__raw, {path: "/"});
|
||||||
setIsFullyAuthenticated(true);
|
setIsFullyAuthenticated(true);
|
||||||
@ -131,8 +128,6 @@ export default function App()
|
|||||||
layout,
|
layout,
|
||||||
openConfigurator,
|
openConfigurator,
|
||||||
sidenavColor,
|
sidenavColor,
|
||||||
transparentSidenav,
|
|
||||||
whiteSidenav,
|
|
||||||
darkMode,
|
darkMode,
|
||||||
} = controller;
|
} = controller;
|
||||||
const [onMouseEnter, setOnMouseEnter] = useState(false);
|
const [onMouseEnter, setOnMouseEnter] = useState(false);
|
||||||
@ -156,8 +151,7 @@ export default function App()
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
console.log("ok now loading qqq things");
|
const metaData = await QClient.getInstance().loadMetaData();
|
||||||
const metaData = await QClient.loadMetaData();
|
|
||||||
|
|
||||||
// get the keys sorted
|
// get the keys sorted
|
||||||
const keys = [...metaData.tables.keys()].sort((a, b): number =>
|
const keys = [...metaData.tables.keys()].sort((a, b): number =>
|
||||||
@ -203,12 +197,6 @@ export default function App()
|
|||||||
route: "/pages/account/settings",
|
route: "/pages/account/settings",
|
||||||
component: <Settings />,
|
component: <Settings />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "Logout",
|
|
||||||
key: "logout",
|
|
||||||
route: "/authentication/sign-in/basic",
|
|
||||||
component: <SignInBasic />,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -294,27 +282,6 @@ export default function App()
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const authButton = (
|
|
||||||
<MDBox
|
|
||||||
display="flex"
|
|
||||||
justifyContent="center"
|
|
||||||
alignItems="center"
|
|
||||||
width="3.25rem"
|
|
||||||
height="3.25rem"
|
|
||||||
bgColor="white"
|
|
||||||
shadow="sm"
|
|
||||||
borderRadius="50%"
|
|
||||||
position="fixed"
|
|
||||||
right="2rem"
|
|
||||||
bottom="2rem"
|
|
||||||
zIndex={99}
|
|
||||||
color="dark"
|
|
||||||
sx={{cursor: "pointer"}}
|
|
||||||
>
|
|
||||||
<AuthenticationButton />
|
|
||||||
</MDBox>
|
|
||||||
);
|
|
||||||
|
|
||||||
const configsButton = (
|
const configsButton = (
|
||||||
<MDBox
|
<MDBox
|
||||||
display="flex"
|
display="flex"
|
||||||
@ -360,7 +327,6 @@ export default function App()
|
|||||||
/>
|
/>
|
||||||
<Configurator />
|
<Configurator />
|
||||||
{configsButton}
|
{configsButton}
|
||||||
{authButton}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Routes>
|
<Routes>
|
||||||
|
29
src/HandleAuthorizationError.tsx
Normal file
29
src/HandleAuthorizationError.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import React, {
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
JSXElementConstructor,
|
||||||
|
Key,
|
||||||
|
ReactElement,
|
||||||
|
} from "react";
|
||||||
|
import {SESSION_ID_COOKIE_NAME} from "App";
|
||||||
|
import {useCookies} from "react-cookie";
|
||||||
|
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||||
|
|
||||||
|
interface Props
|
||||||
|
{
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HandleAuthorizationError({errorMessage}: Props)
|
||||||
|
{
|
||||||
|
const [, , removeCookie] = useCookies([SESSION_ID_COOKIE_NAME]);
|
||||||
|
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
removeCookie(SESSION_ID_COOKIE_NAME, {path: "/"});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>{errorMessage}</div>
|
||||||
|
);
|
||||||
|
}
|
@ -44,6 +44,7 @@ import {
|
|||||||
setTransparentSidenav,
|
setTransparentSidenav,
|
||||||
setWhiteSidenav,
|
setWhiteSidenav,
|
||||||
} from "context";
|
} from "context";
|
||||||
|
import AuthenticationButton from "qqq/components/Buttons/AuthenticationButton";
|
||||||
|
|
||||||
// Declaring props types for Sidenav
|
// Declaring props types for Sidenav
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -314,6 +315,7 @@ function Sidenav({ color, brand, brandName, routes, ...rest }: Props): JSX.Eleme
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<List>{renderRoutes}</List>
|
<List>{renderRoutes}</List>
|
||||||
|
<AuthenticationButton />
|
||||||
</SidenavRoot>
|
</SidenavRoot>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import {BrowserRouter, useNavigate} from "react-router-dom";
|
import {BrowserRouter, useNavigate, useSearchParams} from "react-router-dom";
|
||||||
import {Auth0Provider} from "@auth0/auth0-react";
|
import {Auth0Provider} from "@auth0/auth0-react";
|
||||||
import App from "App";
|
import App from "App";
|
||||||
|
|
||||||
@ -8,39 +8,39 @@ import {MaterialUIControllerProvider} from "context";
|
|||||||
import "./qqq/styles/qqq-override-styles.css";
|
import "./qqq/styles/qqq-override-styles.css";
|
||||||
import ProtectedRoute from "qqq/auth0/protected-route";
|
import ProtectedRoute from "qqq/auth0/protected-route";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import HandleAuthorizationError from "HandleAuthorizationError";
|
||||||
|
|
||||||
// Auth0 params from env
|
// Auth0 params from env
|
||||||
const domain = process.env.REACT_APP_AUTH0_DOMAIN;
|
const domain = process.env.REACT_APP_AUTH0_DOMAIN;
|
||||||
const clientId = process.env.REACT_APP_AUTH0_CLIENT_ID;
|
const clientId = process.env.REACT_APP_AUTH0_CLIENT_ID;
|
||||||
|
|
||||||
/*
|
|
||||||
ReactDOM.render(
|
|
||||||
<BrowserRouter>
|
|
||||||
<MaterialUIControllerProvider>
|
|
||||||
<ProtectedRoute component={App} />
|
|
||||||
</MaterialUIControllerProvider>
|
|
||||||
</BrowserRouter>,
|
|
||||||
document.getElementById("root"),
|
|
||||||
);
|
|
||||||
*/
|
|
||||||
|
|
||||||
console.log("what");
|
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
function Auth0ProviderWithRedirectCallback({children, ...props})
|
function Auth0ProviderWithRedirectCallback({children, ...props})
|
||||||
{
|
{
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const onRedirectCallback = (appState) =>
|
const onRedirectCallback = (appState) =>
|
||||||
{
|
{
|
||||||
navigate((appState && appState.returnTo) || window.location.pathname);
|
navigate((appState && appState.returnTo) || window.location.pathname);
|
||||||
};
|
};
|
||||||
return (
|
if (searchParams.get("error"))
|
||||||
// @ts-ignore
|
{
|
||||||
<Auth0Provider onRedirectCallback={onRedirectCallback} {...props}>
|
return (
|
||||||
{children}
|
// @ts-ignore
|
||||||
</Auth0Provider>
|
<HandleAuthorizationError errorMessage={searchParams.get("error_description")} />
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return (
|
||||||
|
// @ts-ignore
|
||||||
|
<Auth0Provider onRedirectCallback={onRedirectCallback} {...props}>
|
||||||
|
{children}
|
||||||
|
</Auth0Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ReactDOM.render(
|
ReactDOM.render(
|
||||||
@ -57,5 +57,3 @@ ReactDOM.render(
|
|||||||
</BrowserRouter>,
|
</BrowserRouter>,
|
||||||
document.getElementById("root"),
|
document.getElementById("root"),
|
||||||
);
|
);
|
||||||
|
|
||||||
export * from "components/MDButton";
|
|
||||||
|
@ -21,6 +21,7 @@ import {Alert} from "@mui/material";
|
|||||||
import MDBox from "components/MDBox";
|
import MDBox from "components/MDBox";
|
||||||
import MDTypography from "components/MDTypography";
|
import MDTypography from "components/MDTypography";
|
||||||
import MDButton from "../../../components/MDButton";
|
import MDButton from "../../../components/MDButton";
|
||||||
|
import QClient from "qqq/utils/QClient";
|
||||||
|
|
||||||
// Declaring props types for EntityForm
|
// Declaring props types for EntityForm
|
||||||
interface Props
|
interface Props
|
||||||
@ -30,7 +31,7 @@ interface Props
|
|||||||
|
|
||||||
function EntityForm({id}: Props): JSX.Element
|
function EntityForm({id}: Props): JSX.Element
|
||||||
{
|
{
|
||||||
const qController = new QController("");
|
const qController = QClient.getInstance();
|
||||||
const {tableName} = useParams();
|
const {tableName} = useParams();
|
||||||
|
|
||||||
const [validations, setValidations] = useState({});
|
const [validations, setValidations] = useState({});
|
||||||
|
@ -38,7 +38,6 @@ import {
|
|||||||
} from "context";
|
} from "context";
|
||||||
|
|
||||||
// qqq
|
// qqq
|
||||||
import AuthenticationButton from "qqq/components/buttons/AuthenticationButton";
|
|
||||||
|
|
||||||
// Declaring prop types for Navbar
|
// Declaring prop types for Navbar
|
||||||
interface Props
|
interface Props
|
||||||
@ -159,14 +158,6 @@ function Navbar({absolute, light, isMini}: Props): JSX.Element
|
|||||||
<MDInput label="Search here" />
|
<MDInput label="Search here" />
|
||||||
</MDBox>
|
</MDBox>
|
||||||
<MDBox color={light ? "white" : "inherit"}>
|
<MDBox color={light ? "white" : "inherit"}>
|
||||||
<AuthenticationButton />
|
|
||||||
{ /*
|
|
||||||
<Link to="/authentication/sign-in/basic">
|
|
||||||
<IconButton sx={navbarIconButton} size="small" disableRipple>
|
|
||||||
<Icon sx={iconsStyle}>account_circle</Icon>
|
|
||||||
</IconButton>
|
|
||||||
</Link>
|
|
||||||
*/ }
|
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
disableRipple
|
disableRipple
|
||||||
|
@ -66,6 +66,9 @@ class DynamicFormUtils
|
|||||||
case QFieldType.BLOB:
|
case QFieldType.BLOB:
|
||||||
fieldType = "file";
|
fieldType = "file";
|
||||||
break;
|
break;
|
||||||
|
case QFieldType.BOOLEAN:
|
||||||
|
fieldType = "checkbox";
|
||||||
|
break;
|
||||||
case QFieldType.TEXT:
|
case QFieldType.TEXT:
|
||||||
case QFieldType.HTML:
|
case QFieldType.HTML:
|
||||||
case QFieldType.STRING:
|
case QFieldType.STRING:
|
||||||
|
@ -87,6 +87,7 @@ function EntityList({table}: Props): JSX.Element
|
|||||||
const columnVisibilityLocalStorageKey = `${COLUMN_VISIBILITY_LOCAL_STORAGE_KEY_ROOT}.${tableName}`;
|
const columnVisibilityLocalStorageKey = `${COLUMN_VISIBILITY_LOCAL_STORAGE_KEY_ROOT}.${tableName}`;
|
||||||
let defaultSort = [] as GridSortItem[];
|
let defaultSort = [] as GridSortItem[];
|
||||||
let defaultVisibility = {};
|
let defaultVisibility = {};
|
||||||
|
const qController = QClient.getInstance();
|
||||||
|
|
||||||
if (localStorage.getItem(sortLocalStorageKey))
|
if (localStorage.getItem(sortLocalStorageKey))
|
||||||
{
|
{
|
||||||
@ -193,7 +194,7 @@ function EntityList({table}: Props): JSX.Element
|
|||||||
{
|
{
|
||||||
(async () =>
|
(async () =>
|
||||||
{
|
{
|
||||||
const newTableMetaData = await QClient.loadTableMetaData(tableName);
|
const newTableMetaData = await qController.loadTableMetaData(tableName);
|
||||||
setTableMetaData(newTableMetaData);
|
setTableMetaData(newTableMetaData);
|
||||||
if (columnSortModel.length === 0)
|
if (columnSortModel.length === 0)
|
||||||
{
|
{
|
||||||
@ -206,14 +207,14 @@ function EntityList({table}: Props): JSX.Element
|
|||||||
|
|
||||||
const qFilter = buildQFilter();
|
const qFilter = buildQFilter();
|
||||||
|
|
||||||
const count = await QClient.count(tableName, qFilter);
|
const count = await qController.count(tableName, qFilter);
|
||||||
setTotalRecords(count);
|
setTotalRecords(count);
|
||||||
setButtonText(`new ${newTableMetaData.label}`);
|
setButtonText(`new ${newTableMetaData.label}`);
|
||||||
setTableLabel(newTableMetaData.label);
|
setTableLabel(newTableMetaData.label);
|
||||||
|
|
||||||
const columns = [] as GridColDef[];
|
const columns = [] as GridColDef[];
|
||||||
|
|
||||||
const results = await QClient.query(
|
const results = await qController.query(
|
||||||
tableName,
|
tableName,
|
||||||
qFilter,
|
qFilter,
|
||||||
rowsPerPage,
|
rowsPerPage,
|
||||||
@ -362,7 +363,7 @@ function EntityList({table}: Props): JSX.Element
|
|||||||
setTableState(tableName);
|
setTableState(tableName);
|
||||||
setFilterModel(null);
|
setFilterModel(null);
|
||||||
setFiltersMenu(null);
|
setFiltersMenu(null);
|
||||||
const metaData = await QClient.loadMetaData();
|
const metaData = await qController.loadMetaData();
|
||||||
|
|
||||||
setTableProcesses(QProcessUtils.getProcessesForTable(metaData, tableName));
|
setTableProcesses(QProcessUtils.getProcessesForTable(metaData, tableName));
|
||||||
|
|
||||||
|
@ -38,8 +38,9 @@ import Icon from "@mui/material/Icon";
|
|||||||
import MDAlert from "components/MDAlert";
|
import MDAlert from "components/MDAlert";
|
||||||
import MDButton from "../../../../../components/MDButton";
|
import MDButton from "../../../../../components/MDButton";
|
||||||
import QProcessUtils from "../../../../utils/QProcessUtils";
|
import QProcessUtils from "../../../../utils/QProcessUtils";
|
||||||
|
import QClient from "qqq/utils/QClient";
|
||||||
|
|
||||||
const qController = new QController("");
|
const qController = QClient.getInstance();
|
||||||
|
|
||||||
// Declaring props types for ViewForm
|
// Declaring props types for ViewForm
|
||||||
interface Props
|
interface Props
|
||||||
|
@ -19,9 +19,9 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
|
|
||||||
import {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController";
|
import {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController";
|
||||||
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
|
import {QException} from "@kingsrook/qqq-frontend-core/lib/exceptions/QException";
|
||||||
|
import {useAuth0} from "@auth0/auth0-react";
|
||||||
|
|
||||||
/*******************************************************************************
|
/*******************************************************************************
|
||||||
** client wrapper of qqq backend
|
** client wrapper of qqq backend
|
||||||
@ -31,40 +31,25 @@ class QClient
|
|||||||
{
|
{
|
||||||
private static qController: QController;
|
private static qController: QController;
|
||||||
|
|
||||||
|
private static handleException(exception: QException)
|
||||||
|
{
|
||||||
|
console.log(`Caught Exception: ${JSON.stringify(exception)}`);
|
||||||
|
const {logout} = useAuth0();
|
||||||
|
if (exception.status === "401")
|
||||||
|
{
|
||||||
|
logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static getInstance()
|
public static getInstance()
|
||||||
{
|
{
|
||||||
if (this.qController == null)
|
if (this.qController == null)
|
||||||
{
|
{
|
||||||
this.qController = new QController("");
|
this.qController = new QController("", this.handleException);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.qController;
|
return this.qController;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static loadTableMetaData(tableName: string)
|
|
||||||
{
|
|
||||||
return this.getInstance().loadTableMetaData(tableName);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static loadMetaData()
|
|
||||||
{
|
|
||||||
return this.getInstance().loadMetaData();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static query(tableName: string, filter: QQueryFilter, limit: number, skip: number)
|
|
||||||
{
|
|
||||||
return this.getInstance()
|
|
||||||
.query(tableName, filter, limit, skip)
|
|
||||||
.catch((error) =>
|
|
||||||
{
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public static count(tableName: string, filter: QQueryFilter)
|
|
||||||
{
|
|
||||||
return this.getInstance().count(tableName, filter);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default QClient;
|
export default QClient;
|
||||||
|
Reference in New Issue
Block a user