Skip to content
Snippets Groups Projects
Commit 7fa97f74 authored by Mohammad Torkashvand's avatar Mohammad Torkashvand
Browse files

add refresh token to the old gui (2.4) 1.20.0

parent b15f27ec
No related branches found
No related tags found
No related merge requests found
Pipeline #89330 passed
ENVIRONMENT_NAME=Development ENVIRONMENT_NAME=Development
PROCESS_DETAIL_REFETCH_INTERVAL=3000
ORCHESTRATOR_API_HOST=http://localhost:8080 ORCHESTRATOR_API_HOST=http://localhost:8080
ORCHESTRATOR_API_PATH=/api ORCHESTRATOR_API_PATH=/api
ORCHESTRATOR_GRAPHQL_HOST=http://localhost:8080 ORCHESTRATOR_GRAPHQL_HOST=http://localhost:8080
ORCHESTRATOR_GRAPHQL_PATH=/api/graphql ORCHESTRATOR_GRAPHQL_PATH=/api/graphql
ORCHESTRATOR_WEBSOCKET_URL=ws://localhost:8080 ORCHESTRATOR_WEBSOCKET_URL=ws://localhost:8080
USE_WEB_SOCKETS=true
USE_THEME_TOGGLE=true
SHOW_WORKFLOW_INFORMATION_LINK=true
WORKFLOW_INFORMATION_LINK_URL="https://workfloworchestrator.org/"
# Auth variables
OAUTH2_ACTIVE=true
NEXTAUTH_PROVIDER_ID="oidc"
NEXTAUTH_PROVIDER_NAME="GÉANT Identity Provider"
NEXTAUTH_AUTHORIZATION_SCOPE_OVERRIDE="openid profile email aarc offline_access"
OAUTH2_CLIENT_ID="APP-A43E6FB7-1EEF-49CB-95B9-9AFF6FA7EF66"
OAUTH2_CLIENT_SECRET=""
OIDC_CONF_FULL_WELL_KNOWN_URL="https://proxy.aai.geant.org/.well-known/openid-configuration"
OIDC_TOKEN_ENDPOINT="https://proxy.aai.geant.org/OIDC/token"
# OPA Settings AUTH_ACTIVE=true
OPA_PUBLIC_BUNDLE_URL="http://localhost:8000/policy.wasm"
#Maps Settings
NETWORK_TOPOLOGY_API_URL="http://localhost:8080/api/v1/networks/topology"
# Required by the Nextauth middleware
NEXTAUTH_URL=http://localhost:3000/api/auth NEXTAUTH_URL=http://localhost:3000/api/auth
NEXTAUTH_SECRET="LR1a4CU9UVFr1OcVeu4ULDX/VHMMiI+s3wNvOkf6fdE=" # openssl rand -base64 32
# OIDC Authentication Settings
NEXTAUTH_ID="oidc"
NEXTAUTH_CLIENT_ID="APP-A43E6FB7-1EEF-49CB-95B9-9AFF6FA7EF66"
NEXTAUTH_CLIENT_SECRET="YOUR_OIDC_CLIENT_SECRET"
NEXTAUTH_SECRET="SOhxHLn53mV7ML7y8L6rL5oOQxOVb0V4p2Ez0ZSIuOs=" # openssl rand -base64 32
NEXTAUTH_ISSUER="https://proxy.aai.geant.org"
NEXTAUTH_WELL_KNOWN_OVERRIDE="https://proxy.aai.geant.org/.well-known/openid-configuration"
OIDC_TOKEN_ENDPOINT="https://proxy.aai.geant.org/OIDC/token"
# docker-compose variables
# KEYCLOAK_ADMIN=admin
# KEYCLOAK_ADMIN_PASSWORD=admin
# KEYCLOAK_PORT=8085
USE_WEBSOCKET=true
USE_THEME_TOGGLE=true
\ No newline at end of file
import { getEnvironmentVariables } from '@/utils/getEnvironmentVariables';
import { import {
Environment, Environment,
OrchestratorConfig, OrchestratorConfig,
getEnvironmentVariables,
} from '@orchestrator-ui/orchestrator-ui-components'; } from '@orchestrator-ui/orchestrator-ui-components';
const DEFAULT_GRAPHQL_CORE_ENDPOINT = 'http://localhost:8080/api/graphql';
const DEFAULT_ORCHESTRATOR_API_BASE_URL = 'http://localhost:8080/api';
const DEFAULT_ORCHESTRATOR_WEBSOCKET_URL = 'ws://localhost:8080';
const ENGINE_STATUS_ENDPOINT = '/settings/status';
const PROCESSES_ENDPOINT = '/processes';
const SUBSCRIPTION_PROCESSES_ENDPOINT =
'/processes/process-subscriptions-by-subscription-id';
const DEFAULT_WORKFLOW_INFORMATION_LINK_URL = 'http://localhost:8080';
type OrchestratorEnvVars = {
ORCHESTRATOR_GRAPHQL_HOST: string;
ORCHESTRATOR_GRAPHQL_PATH: string;
ORCHESTRATOR_API_HOST: string;
ORCHESTRATOR_API_PATH: string;
ENVIRONMENT_NAME: string;
ORCHESTRATOR_WEBSOCKET_URL: string;
AUTH_ACTIVE: string;
USE_WEB_SOCKETS: string;
USE_THEME_TOGGLE: string;
WORKFLOW_INFORMATION_LINK_URL: string;
SHOW_WORKFLOW_INFORMATION_LINK: string;
};
export const getInitialOrchestratorConfig = (): OrchestratorConfig => { export const getInitialOrchestratorConfig = (): OrchestratorConfig => {
const { const env = getEnvironmentVariables<OrchestratorEnvVars>([
USE_THEME_TOGGLE,
ENVIRONMENT_NAME,
ORCHESTRATOR_API_HOST,
ORCHESTRATOR_API_PATH,
ORCHESTRATOR_GRAPHQL_HOST,
ORCHESTRATOR_GRAPHQL_PATH,
ORCHESTRATOR_WEBSOCKET_URL,
USE_WEB_SOCKETS,
WORKFLOW_INFORMATION_LINK_URL,
SHOW_WORKFLOW_INFORMATION_LINK,
OAUTH2_ACTIVE,
ENABLE_SUPPORT_MENU_ITEM,
SUPPORT_MENU_ITEM_URL,
} = getEnvironmentVariables([
'USE_THEME_TOGGLE',
'ENVIRONMENT_NAME',
'ORCHESTRATOR_API_HOST',
'ORCHESTRATOR_API_PATH',
'ORCHESTRATOR_GRAPHQL_HOST', 'ORCHESTRATOR_GRAPHQL_HOST',
'ORCHESTRATOR_GRAPHQL_PATH', 'ORCHESTRATOR_GRAPHQL_PATH',
'ORCHESTRATOR_API_HOST',
'ORCHESTRATOR_API_PATH',
'ENVIRONMENT_NAME',
'ORCHESTRATOR_WEBSOCKET_URL', 'ORCHESTRATOR_WEBSOCKET_URL',
'AUTH_ACTIVE',
'USE_WEB_SOCKETS', 'USE_WEB_SOCKETS',
'USE_THEME_TOGGLE',
'WORKFLOW_INFORMATION_LINK_URL', 'WORKFLOW_INFORMATION_LINK_URL',
'SHOW_WORKFLOW_INFORMATION_LINK', 'SHOW_WORKFLOW_INFORMATION_LINK',
'OAUTH2_ACTIVE',
'ENABLE_SUPPORT_MENU_ITEM',
'SUPPORT_MENU_ITEM_URL',
]); ]);
const graphqlEndpointCore = `${ORCHESTRATOR_GRAPHQL_HOST}${ORCHESTRATOR_GRAPHQL_PATH}`; const orchestratorGraphqlBaseUrl =
const orchestratorApiBaseUrl = `${ORCHESTRATOR_API_HOST}${ORCHESTRATOR_API_PATH}`; env.ORCHESTRATOR_GRAPHQL_HOST && env.ORCHESTRATOR_GRAPHQL_PATH
? `${env.ORCHESTRATOR_GRAPHQL_HOST}${env.ORCHESTRATOR_GRAPHQL_PATH}`
: DEFAULT_GRAPHQL_CORE_ENDPOINT;
const orchestratorApiBaseUrl =
env.ORCHESTRATOR_API_HOST && env.ORCHESTRATOR_API_PATH
? `${env.ORCHESTRATOR_API_HOST}${env.ORCHESTRATOR_API_PATH}`
: DEFAULT_ORCHESTRATOR_API_BASE_URL;
return { return {
orchestratorApiBaseUrl, orchestratorApiBaseUrl,
graphqlEndpointCore, engineStatusEndpoint: `${orchestratorApiBaseUrl}${ENGINE_STATUS_ENDPOINT}`,
environmentName: ENVIRONMENT_NAME ?? Environment.DEVELOPMENT, graphqlEndpointCore: orchestratorGraphqlBaseUrl,
orchestratorWebsocketUrl: ORCHESTRATOR_WEBSOCKET_URL, processesEndpoint: `${orchestratorApiBaseUrl}${PROCESSES_ENDPOINT}`,
authActive: OAUTH2_ACTIVE?.toLowerCase() != 'false', environmentName: env.ENVIRONMENT_NAME ?? Environment.DEVELOPMENT,
useWebSockets: USE_WEB_SOCKETS?.toLowerCase() === 'true', subscriptionProcessesEndpoint: `${orchestratorApiBaseUrl}${SUBSCRIPTION_PROCESSES_ENDPOINT}`,
useThemeToggle: USE_THEME_TOGGLE?.toLowerCase() === 'true', orchestratorWebsocketUrl:
workflowInformationLinkUrl: WORKFLOW_INFORMATION_LINK_URL, env.ORCHESTRATOR_WEBSOCKET_URL ?? DEFAULT_ORCHESTRATOR_WEBSOCKET_URL,
authActive: env.AUTH_ACTIVE?.toLowerCase() !== 'false',
useWebSockets: env.USE_WEB_SOCKETS?.toLowerCase() === 'true',
useThemeToggle: env.USE_THEME_TOGGLE?.toLowerCase() === 'true',
workflowInformationLinkUrl:
env.WORKFLOW_INFORMATION_LINK_URL ??
DEFAULT_WORKFLOW_INFORMATION_LINK_URL,
showWorkflowInformationLink: showWorkflowInformationLink:
SHOW_WORKFLOW_INFORMATION_LINK?.toLowerCase() === 'true', env.SHOW_WORKFLOW_INFORMATION_LINK?.toLowerCase() === 'true',
enableSupportMenuItem: ENABLE_SUPPORT_MENU_ITEM?.toLowerCase() === 'true',
supportMenuItemUrl: SUPPORT_MENU_ITEM_URL,
}; };
}; };
import axios from 'axios';
import { signOut } from 'next-auth/react';
import { useEffect } from 'react';
const useAxiosInterceptor = () => {
useEffect(() => {
const responseInterceptor = axios.interceptors.response.use(
(response) => response,
(error) => {
if (error.response && error.response.status === 401) {
const currentUrl = window.location.href;
signOut({
callbackUrl: `/api/auth/signin?error=SessionRequired&callbackUrl=${encodeURIComponent(
currentUrl,
)}`,
});
}
return Promise.reject(error);
},
);
return () => {
axios.interceptors.response.eject(responseInterceptor);
};
}, []);
};
export default useAxiosInterceptor;
import { signOut } from 'next-auth/react';
import { useEffect } from 'react';
const useFetchInterceptor = () => {
useEffect(() => {
const handleResponse = (response: Response) => {
if (response.status === 401) {
const currentUrl = window.location.href;
signOut({
callbackUrl: `/api/auth/signin?error=SessionRequired&callbackUrl=${encodeURIComponent(
currentUrl,
)}`,
});
}
return response;
};
const originalFetch = global.fetch.bind(global);
global.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const response = await originalFetch(input, init);
return handleResponse(response);
};
return () => {
global.fetch = originalFetch;
};
}, []);
};
export default useFetchInterceptor;
...@@ -10,6 +10,6 @@ module.exports = { ...@@ -10,6 +10,6 @@ module.exports = {
transpilePackages: ['@orchestrator-ui/orchestrator-ui-components'], transpilePackages: ['@orchestrator-ui/orchestrator-ui-components'],
publicRuntimeConfig: { publicRuntimeConfig: {
OPA_PUBLIC_BUNDLE_URL: process.env.OPA_PUBLIC_BUNDLE_URL, OPA_PUBLIC_BUNDLE_URL: process.env.OPA_PUBLIC_BUNDLE_URL,
OAUTH2_CLIENT_ID: process.env.OAUTH2_CLIENT_ID, NEXTAUTH_CLIENT_ID: process.env.NEXTAUTH_CLIENT_ID,
}, },
}; };
This diff is collapsed.
...@@ -16,12 +16,12 @@ ...@@ -16,12 +16,12 @@
}, },
"dependencies": { "dependencies": {
"@elastic/datemath": "^5.0.3", "@elastic/datemath": "^5.0.3",
"@elastic/eui": "^95.1.0", "@elastic/eui": "^93.1.1",
"@elfalem/leaflet-curve": "^0.9.2", "@elfalem/leaflet-curve": "^0.9.2",
"@emotion/css": "^11.11.2", "@emotion/css": "^11.11.2",
"@emotion/react": "^11.11.1", "@emotion/react": "^11.11.1",
"@open-policy-agent/opa-wasm": "^1.8.1", "@open-policy-agent/opa-wasm": "^1.8.1",
"@orchestrator-ui/orchestrator-ui-components": "2.1.1", "@orchestrator-ui/orchestrator-ui-components": "1.20.0",
"@reduxjs/toolkit": "^2.0.1", "@reduxjs/toolkit": "^2.0.1",
"axios": "^1.7.2", "axios": "^1.7.2",
"cytoscape": "^3.29.2", "cytoscape": "^3.29.2",
...@@ -43,7 +43,7 @@ ...@@ -43,7 +43,7 @@
"react-leaflet-markercluster": "^3.0.0-rc1", "react-leaflet-markercluster": "^3.0.0-rc1",
"react-no-ssr": "^1.1.0", "react-no-ssr": "^1.1.0",
"react-query": "3.39.3", "react-query": "3.39.3",
"react-redux": "^8.1.3", "react-redux": "^9.1.0",
"use-query-params": "2.2.1" "use-query-params": "2.2.1"
}, },
"devDependencies": { "devDependencies": {
...@@ -55,25 +55,23 @@ ...@@ -55,25 +55,23 @@
"@types/cytoscape-fcose": "^2.2.4", "@types/cytoscape-fcose": "^2.2.4",
"@types/leaflet": "^1.9.12", "@types/leaflet": "^1.9.12",
"@types/node": "^20.10.5", "@types/node": "^20.10.5",
"@types/node-fetch": "^2.6.11", "@types/react": "^18.3.3",
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18", "@types/react-dom": "^18.2.18",
"@types/react-leaflet-markercluster": "^3.0.4", "@types/react-leaflet-markercluster": "^3.0.4",
"@types/react-no-ssr": "^1.1.7", "@types/react-no-ssr": "^1.1.7",
"babel-jest": "^29.7.0",
"esbuild-jest": "^0.4.0", "esbuild-jest": "^0.4.0",
"eslint-config-prettier": "^8.3.0", "eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^3.4.0", "eslint-plugin-prettier": "^3.4.0",
"husky": "^9.0.11", "husky": "^9.0.11",
"prettier": "^2.3.2", "prettier": "^2.3.2",
"typescript": "^5.5.2" "typescript": "^5.3.2"
}, },
"overrides": { "overrides": {
"@elastic/eui": { "@elastic/eui": {
"typescript": "^5.5.2" "typescript": "^5.3.2"
}, },
"react-no-ssr": { "react-no-ssr": {
"react": "^18.3.1" "react": "^18.2.0"
} }
} }
} }
...@@ -3,21 +3,25 @@ import { getAppLogo } from '@/components/AppLogo/AppLogo'; ...@@ -3,21 +3,25 @@ import { getAppLogo } from '@/components/AppLogo/AppLogo';
import { WfoAuthWithPolicy } from '@/components/WfoAuthWithPolicy'; import { WfoAuthWithPolicy } from '@/components/WfoAuthWithPolicy';
import { getInitialOrchestratorConfig } from '@/configuration'; import { getInitialOrchestratorConfig } from '@/configuration';
import { GsoConfigProvider, GsoConfig } from '@/contexts/GsoConfigContext'; import { GsoConfigProvider, GsoConfig } from '@/contexts/GsoConfigContext';
import useAxiosInterceptor from '@/hooks/useAxiosInterceptor';
import useFetchInterceptor from '@/hooks/useFetchInterceptor';
import { TranslationsProvider } from '@/translations/translationsProvider'; import { TranslationsProvider } from '@/translations/translationsProvider';
import type { EuiSideNavItemType } from '@elastic/eui';
import { EuiProvider, EuiThemeColorMode } from '@elastic/eui'; import { EuiProvider, EuiThemeColorMode } from '@elastic/eui';
import '@elastic/eui/dist/eui_theme_dark.min.css';
import '@elastic/eui/dist/eui_theme_light.min.css'; import '@elastic/eui/dist/eui_theme_light.min.css';
import { EuiSideNavItemType } from '@elastic/eui/src/components/side_nav/side_nav_types';
import { import {
ApiClientContextProvider,
ColorModes, ColorModes,
ConfirmationDialogContextWrapper, ConfirmationDialogContextWrapper,
OrchestratorConfig, OrchestratorConfig,
OrchestratorConfigProvider, OrchestratorConfigProvider,
StoreProvider, StoreProvider,
WfoErrorBoundary, WfoErrorBoundary,
WfoMenuItemLink,
WfoPageTemplate, WfoPageTemplate,
WfoToastsList, WfoToastsList,
defaultOrchestratorTheme, defaultOrchestratorTheme,
WfoMenuItemLink,
} from '@orchestrator-ui/orchestrator-ui-components'; } from '@orchestrator-ui/orchestrator-ui-components';
import { SessionProvider } from 'next-auth/react'; import { SessionProvider } from 'next-auth/react';
import { NextAdapter } from 'next-query-params'; import { NextAdapter } from 'next-query-params';
...@@ -26,17 +30,36 @@ import Head from 'next/head'; ...@@ -26,17 +30,36 @@ import Head from 'next/head';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import NoSSR from 'react-no-ssr'; import NoSSR from 'react-no-ssr';
import { QueryClient, QueryClientProvider } from 'react-query';
import { ReactQueryDevtools } from 'react-query/devtools';
import { QueryClientConfig } from 'react-query/types/core/types';
import { QueryParamProvider } from 'use-query-params'; import { QueryParamProvider } from 'use-query-params';
type AppOwnProps = { orchestratorConfig: OrchestratorConfig }; type AppOwnProps = { orchestratorConfig: OrchestratorConfig };
const queryClientConfig: QueryClientConfig = {
defaultOptions: {
queries: {
cacheTime: 5 * 1000,
refetchOnWindowFocus: true,
},
},
};
const useAuthInterceptor = () => {
useFetchInterceptor();
useAxiosInterceptor();
};
function CustomApp({ function CustomApp({
Component, Component,
pageProps, pageProps,
orchestratorConfig, orchestratorConfig,
}: AppProps & AppOwnProps) { }: AppProps & AppOwnProps) {
const router = useRouter(); const router = useRouter();
useAuthInterceptor();
const [queryClient] = useState(() => new QueryClient(queryClientConfig));
const [themeMode, setThemeMode] = useState<EuiThemeColorMode>( const [themeMode, setThemeMode] = useState<EuiThemeColorMode>(
ColorModes.LIGHT, ColorModes.LIGHT,
); );
...@@ -105,32 +128,40 @@ function CustomApp({ ...@@ -105,32 +128,40 @@ function CustomApp({
colorMode={themeMode} colorMode={themeMode}
modify={defaultOrchestratorTheme} modify={defaultOrchestratorTheme}
> >
<TranslationsProvider> <ApiClientContextProvider>
<Head> <QueryClientProvider
<link rel="icon" href="/favicon.png" /> client={queryClient}
<title>GÉANT Service Orchestrator</title> contextSharing={true}
</Head> >
<main className="app"> <TranslationsProvider>
<ConfirmationDialogContextWrapper> <Head>
<WfoPageTemplate <link rel="icon" href="/favicon.png" />
getAppLogo={getAppLogo} <title>GÉANT Service Orchestrator</title>
onThemeSwitch={handleThemeSwitch} </Head>
overrideMenuItems={addMenuItems} <main className="app">
> <ConfirmationDialogContextWrapper>
<QueryParamProvider <WfoPageTemplate
adapter={NextAdapter} getAppLogo={getAppLogo}
options={{ onThemeSwitch={handleThemeSwitch}
removeDefaultsFromUrl: false, overrideMenuItems={addMenuItems}
enableBatching: true, >
}} <QueryParamProvider
> adapter={NextAdapter}
<Component {...pageProps} /> options={{
</QueryParamProvider> removeDefaultsFromUrl: false,
</WfoPageTemplate> enableBatching: true,
<WfoToastsList /> }}
</ConfirmationDialogContextWrapper> >
</main> <Component {...pageProps} />
</TranslationsProvider> </QueryParamProvider>
</WfoPageTemplate>
<WfoToastsList />
</ConfirmationDialogContextWrapper>
<ReactQueryDevtools initialIsOpen={false} />
</main>
</TranslationsProvider>
</QueryClientProvider>
</ApiClientContextProvider>
</EuiProvider> </EuiProvider>
</WfoAuthWithPolicy> </WfoAuthWithPolicy>
</GsoConfigProvider> </GsoConfigProvider>
......
import { getEnvironmentVariables } from '@/utils/getEnvironmentVariables';
import { import {
WfoSession, WfoSession,
WfoUserProfile, WfoUserProfile,
getEnvironmentVariables,
} from '@orchestrator-ui/orchestrator-ui-components'; } from '@orchestrator-ui/orchestrator-ui-components';
import NextAuth, { AuthOptions } from 'next-auth'; import NextAuth, { AuthOptions } from 'next-auth';
import { JWT } from 'next-auth/jwt'; import { JWT } from 'next-auth/jwt';
......
import { getEnvironmentVariables } from '@orchestrator-ui/orchestrator-ui-components';
import { NextApiRequest, NextApiResponse } from 'next'; import { NextApiRequest, NextApiResponse } from 'next';
interface RuntimeConfig { interface RuntimeConfig {
...@@ -7,21 +6,14 @@ interface RuntimeConfig { ...@@ -7,21 +6,14 @@ interface RuntimeConfig {
networkTopologyApiUrl: string; networkTopologyApiUrl: string;
} }
const { OPA_PUBLIC_BUNDLE_URL, OAUTH2_CLIENT_ID, NETWORK_TOPOLOGY_API_URL } =
getEnvironmentVariables([
'OPA_PUBLIC_BUNDLE_URL',
'OAUTH2_CLIENT_ID',
'NETWORK_TOPOLOGY_API_URL',
]);
export default async function handler( export default async function handler(
req: NextApiRequest, req: NextApiRequest,
res: NextApiResponse<RuntimeConfig | { error: string }>, res: NextApiResponse<RuntimeConfig | { error: string }>,
) { ) {
const config: RuntimeConfig = { const config: RuntimeConfig = {
opaPublicBundleUrl: OPA_PUBLIC_BUNDLE_URL || '', opaPublicBundleUrl: process.env.OPA_PUBLIC_BUNDLE_URL || '',
oidcClientId: OAUTH2_CLIENT_ID || '', oidcClientId: process.env.NEXTAUTH_CLIENT_ID || '',
networkTopologyApiUrl: NETWORK_TOPOLOGY_API_URL || '', networkTopologyApiUrl: process.env.NETWORK_TOPOLOGY_API_URL || '',
}; };
res.status(200).json(config); res.status(200).json(config);
......
import process from 'process';
// By convention, only this function should be used to access the process.env object.
// It logs a warning if one or more variables are not set
export function getEnvironmentVariables<T>(
envVars: (keyof T)[],
): Record<keyof T, string> {
const missingEnvironmentVariables: string[] = [];
const environmentVariablesWithValues = envVars.reduce((acc, currentKey) => {
const value = process.env[currentKey.toString()];
if (value === undefined) {
missingEnvironmentVariables.push(currentKey.toString());
}
return {
...acc,
[currentKey]: value || '',
};
}, {} as Record<keyof T, string>);
if (missingEnvironmentVariables.length > 0) {
console.warn(
`Warning: Missing required environment variables: ${missingEnvironmentVariables.join(
', ',
)}`,
);
}
return environmentVariablesWithValues;
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment