mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-15 01:12:56 +03:00
better key persistence in console (#2686)
* change login flow to handle admin secret persistence * handle headers init state * add tooltip for remember-me * remove log, make label clickable * fix a closure scope bug * handle login verification at route level * update Login.js * refactor * remove extra file * refactor * add id to tooltips * remove adminsecretlabel + update admin secret storage flow * fix heartIcon close handling * . * fix admin secret setting * fix urlPrefix * add admin secret header if not present * update jwt analyzer icon * persist if admin secret header has already been added * set cli console mode as constant * handle CLI admin secret errors * make separate logout page * fix typos * fix typos * fix typos * fix typos * fix cli error * fix login page path
This commit is contained in:
parent
eb0c1f6642
commit
1d1de94303
@ -1,3 +1,5 @@
|
||||
import { ADMIN_SECRET_HEADER_KEY } from '../../src/constants';
|
||||
|
||||
export const baseUrl = Cypress.config('baseUrl');
|
||||
export const dataTypes = [
|
||||
'serial',
|
||||
@ -41,7 +43,7 @@ export const makeDataAPIOptions = (dataApiUrl, key, body) => ({
|
||||
method: 'POST',
|
||||
url: makeDataAPIUrl(dataApiUrl),
|
||||
headers: {
|
||||
'x-hasura-admin-secret': key,
|
||||
[ADMIN_SECRET_HEADER_KEY]: key,
|
||||
},
|
||||
body,
|
||||
failOnStatusCode: false,
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { ADMIN_SECRET_HEADER_KEY } from '../../src/constants';
|
||||
|
||||
export const baseUrl = Cypress.config('baseUrl');
|
||||
export const queryTypes = ['insert', 'update', 'delete'];
|
||||
export const getTriggerName = (i, testName = '') =>
|
||||
@ -14,7 +16,7 @@ export const makeDataAPIOptions = (dataApiUrl, key, body) => ({
|
||||
method: 'POST',
|
||||
url: makeDataAPIUrl(dataApiUrl),
|
||||
headers: {
|
||||
'x-hasura-admin-secret': key,
|
||||
[ADMIN_SECRET_HEADER_KEY]: key,
|
||||
},
|
||||
body,
|
||||
failOnStatusCode: false,
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { ADMIN_SECRET_HEADER_KEY } from '../../src/constants';
|
||||
|
||||
export const baseUrl = Cypress.config('baseUrl');
|
||||
export const getRemoteSchemaName = (i, schemaName) =>
|
||||
`test-remote-schema-${schemaName}-${i}`;
|
||||
@ -14,7 +16,7 @@ export const makeDataAPIOptions = (dataApiUrl, key, body) => ({
|
||||
method: 'POST',
|
||||
url: makeDataAPIUrl(dataApiUrl),
|
||||
headers: {
|
||||
'x-hasura-admin-secret': key,
|
||||
[ADMIN_SECRET_HEADER_KEY]: key,
|
||||
},
|
||||
body,
|
||||
failOnStatusCode: false,
|
||||
|
@ -1,71 +1,11 @@
|
||||
import { SERVER_CONSOLE_MODE } from './constants';
|
||||
import { getFeaturesCompatibility } from './helpers/versionUtils';
|
||||
import { stripTrailingSlash } from './components/Common/utils/urlUtils';
|
||||
|
||||
/* helper tools to format explain json */
|
||||
/* set helper tools into window */
|
||||
|
||||
/* eslint-disable */
|
||||
import sqlFormatter from './helpers/sql-formatter.min';
|
||||
import hljs from './helpers/highlight.min';
|
||||
import { getFeaturesCompatibility } from './helpers/versionUtils';
|
||||
/* eslint-enable */
|
||||
|
||||
/* */
|
||||
|
||||
const checkExtraSlashes = url => {
|
||||
if (!url) {
|
||||
return url;
|
||||
}
|
||||
if (url[url.length - 1] === '/') {
|
||||
return url.slice(0, url.length - 1);
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
const globals = {
|
||||
apiHost: window.__env.apiHost,
|
||||
apiPort: window.__env.apiPort,
|
||||
dataApiUrl: checkExtraSlashes(window.__env.dataApiUrl),
|
||||
devDataApiUrl: window.__env.devDataApiUrl,
|
||||
nodeEnv: window.__env.nodeEnv,
|
||||
adminSecret: window.__env.adminSecret || window.__env.accessKey,
|
||||
isAdminSecretSet:
|
||||
window.__env.isAdminSecretSet || window.__env.isAccessKeySet,
|
||||
adminSecretLabel:
|
||||
window.__env.isAdminSecretSet !== undefined ||
|
||||
window.__env.adminSecret !== undefined
|
||||
? 'admin-secret'
|
||||
: 'access-key',
|
||||
consoleMode:
|
||||
window.__env.consoleMode === 'hasuradb'
|
||||
? 'server'
|
||||
: window.__env.consoleMode,
|
||||
urlPrefix: checkExtraSlashes(window.__env.urlPrefix),
|
||||
enableTelemetry: window.__env.enableTelemetry,
|
||||
telemetryTopic:
|
||||
window.__env.nodeEnv !== 'development' ? 'console' : 'console_test',
|
||||
assetsPath: window.__env.assetsPath,
|
||||
serverVersion: window.__env.serverVersion,
|
||||
consoleAssetVersion: CONSOLE_ASSET_VERSION,
|
||||
featuresCompatibility: window.__env.serverVersion
|
||||
? getFeaturesCompatibility(window.__env.serverVersion)
|
||||
: null,
|
||||
};
|
||||
|
||||
// set defaults
|
||||
if (!window.__env.urlPrefix) {
|
||||
globals.urlPrefix = '/';
|
||||
}
|
||||
|
||||
if (!window.__env.consoleMode) {
|
||||
globals.consoleMode = SERVER_CONSOLE_MODE;
|
||||
}
|
||||
|
||||
if (!globals.adminSecret) {
|
||||
globals.adminSecret = null;
|
||||
}
|
||||
|
||||
if (globals.isAdminSecretSet === undefined) {
|
||||
globals.isAdminSecretSet = false;
|
||||
}
|
||||
|
||||
if (
|
||||
window &&
|
||||
@ -77,31 +17,48 @@ if (
|
||||
window.hljs = hljs;
|
||||
}
|
||||
|
||||
/* initialize globals */
|
||||
|
||||
const globals = {
|
||||
apiHost: window.__env.apiHost,
|
||||
apiPort: window.__env.apiPort,
|
||||
dataApiUrl: stripTrailingSlash(window.__env.dataApiUrl),
|
||||
devDataApiUrl: window.__env.devDataApiUrl,
|
||||
nodeEnv: window.__env.nodeEnv,
|
||||
adminSecret: window.__env.adminSecret || null, // will be updated after login/logout
|
||||
isAdminSecretSet: window.__env.isAdminSecretSet || false,
|
||||
consoleMode: window.__env.consoleMode || SERVER_CONSOLE_MODE,
|
||||
urlPrefix: stripTrailingSlash(window.__env.urlPrefix) || '',
|
||||
enableTelemetry: window.__env.enableTelemetry,
|
||||
telemetryTopic:
|
||||
window.__env.nodeEnv !== 'development' ? 'console' : 'console_test',
|
||||
assetsPath: window.__env.assetsPath,
|
||||
serverVersion: window.__env.serverVersion,
|
||||
consoleAssetVersion: CONSOLE_ASSET_VERSION, // set during console build
|
||||
featuresCompatibility: window.__env.serverVersion
|
||||
? getFeaturesCompatibility(window.__env.serverVersion)
|
||||
: null,
|
||||
};
|
||||
|
||||
if (globals.consoleMode === SERVER_CONSOLE_MODE) {
|
||||
if (globals.nodeEnv !== 'development') {
|
||||
if (window.__env.consolePath) {
|
||||
const safeCurrentUrl = checkExtraSlashes(window.location.href);
|
||||
globals.dataApiUrl = safeCurrentUrl.slice(
|
||||
const consolePath = window.__env.consolePath;
|
||||
if (consolePath) {
|
||||
const currentUrl = stripTrailingSlash(window.location.href);
|
||||
globals.dataApiUrl = currentUrl.slice(
|
||||
0,
|
||||
safeCurrentUrl.lastIndexOf(window.__env.consolePath)
|
||||
currentUrl.lastIndexOf(consolePath)
|
||||
);
|
||||
const currentPath = checkExtraSlashes(window.location.pathname);
|
||||
|
||||
const currentPath = stripTrailingSlash(window.location.pathname);
|
||||
globals.urlPrefix =
|
||||
currentPath.slice(
|
||||
0,
|
||||
currentPath.lastIndexOf(window.__env.consolePath)
|
||||
) + '/console';
|
||||
currentPath.slice(0, currentPath.lastIndexOf(consolePath)) + '/console';
|
||||
} else {
|
||||
const windowUrl = window.location.protocol + '//' + window.location.host;
|
||||
|
||||
globals.dataApiUrl = windowUrl;
|
||||
}
|
||||
}
|
||||
/*
|
||||
* Require the exact usecase
|
||||
if (globals.nodeEnv === 'development') {
|
||||
globals.dataApiUrl = globals.devDataApiUrl;
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
export default globals;
|
||||
|
@ -1,10 +1,7 @@
|
||||
import globals from 'Globals';
|
||||
|
||||
const stateKey = 'CONSOLE_LOCAL_INFO:' + globals.dataApiUrl;
|
||||
const CONSOLE_ADMIN_SECRET =
|
||||
globals.adminSecretLabel === 'admin-secret'
|
||||
? 'CONSOLE_ADMIN_SECRET'
|
||||
: 'CONSOLE_ACCESS_KEY';
|
||||
const CONSOLE_ADMIN_SECRET = 'CONSOLE_ADMIN_SECRET';
|
||||
|
||||
const loadAppState = () => JSON.parse(window.localStorage.getItem(stateKey));
|
||||
|
||||
@ -21,6 +18,8 @@ const saveAdminSecretState = state => {
|
||||
|
||||
const clearAdminSecretState = () => {
|
||||
window.localStorage.removeItem(CONSOLE_ADMIN_SECRET);
|
||||
|
||||
globals.adminSecret = null;
|
||||
};
|
||||
|
||||
const clearState = () => window.localStorage.removeItem(stateKey);
|
||||
|
@ -431,6 +431,10 @@ input {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.remove_margin_top {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.display_inline {
|
||||
display: inline-block;
|
||||
}
|
||||
|
@ -9,7 +9,7 @@ const WarningSymbol = ({
|
||||
tooltipPlacement = 'right',
|
||||
customStyle = null,
|
||||
}) => {
|
||||
const tooltip = <Tooltip>{tooltipText}</Tooltip>;
|
||||
const tooltip = <Tooltip id={tooltipText}>{tooltipText}</Tooltip>;
|
||||
|
||||
return (
|
||||
<div className={styles.display_inline}>
|
||||
|
@ -1,3 +1,11 @@
|
||||
export const getPathRoot = path => {
|
||||
return path.split('/')[1];
|
||||
};
|
||||
|
||||
export const stripTrailingSlash = url => {
|
||||
if (url && url.endsWith('/')) {
|
||||
return url.slice(0, -1);
|
||||
}
|
||||
|
||||
return url;
|
||||
};
|
||||
|
@ -4,7 +4,7 @@ import {
|
||||
loadInconsistentObjects,
|
||||
redirectToMetadataStatus,
|
||||
isMetadataStatusPage,
|
||||
} from '../Services/Metadata/Actions';
|
||||
} from '../Services/Settings/Actions';
|
||||
import Spinner from '../Common/Spinner/Spinner';
|
||||
|
||||
import PageNotFound, { NotFoundError } from './PageNotFound';
|
||||
|
67
console/src/components/Login/Actions.js
Normal file
67
console/src/components/Login/Actions.js
Normal file
@ -0,0 +1,67 @@
|
||||
import Endpoints, { globalCookiePolicy } from '../../Endpoints';
|
||||
import { UPDATE_DATA_HEADERS } from '../Services/Data/DataActions';
|
||||
import { saveAdminSecretState } from '../AppState';
|
||||
import { ADMIN_SECRET_HEADER_KEY, CLI_CONSOLE_MODE } from '../../constants';
|
||||
import requestAction from '../../utils/requestAction';
|
||||
import globals from '../../Globals';
|
||||
|
||||
export const verifyLogin = ({
|
||||
adminSecret,
|
||||
shouldPersist,
|
||||
successCallback,
|
||||
errorCallback,
|
||||
dispatch,
|
||||
}) => {
|
||||
const url = Endpoints.getSchema;
|
||||
const requestOptions = {
|
||||
credentials: globalCookiePolicy,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
[ADMIN_SECRET_HEADER_KEY]: adminSecret,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: 'select',
|
||||
args: {
|
||||
table: {
|
||||
name: 'hdb_table',
|
||||
schema: 'hdb_catalog',
|
||||
},
|
||||
columns: ['table_schema'],
|
||||
where: { table_schema: 'public' },
|
||||
limit: 1,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
dispatch(requestAction(url, requestOptions)).then(
|
||||
() => {
|
||||
if (adminSecret) {
|
||||
if (globals.consoleMode !== CLI_CONSOLE_MODE) {
|
||||
// set admin secret to local storage
|
||||
if (shouldPersist) {
|
||||
saveAdminSecretState(adminSecret);
|
||||
}
|
||||
|
||||
// set admin secret in globals
|
||||
globals.adminSecret = adminSecret;
|
||||
}
|
||||
|
||||
// set data headers in redux
|
||||
dispatch({
|
||||
type: UPDATE_DATA_HEADERS,
|
||||
data: {
|
||||
'content-type': 'application/json',
|
||||
[ADMIN_SECRET_HEADER_KEY]: adminSecret,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (successCallback) {
|
||||
successCallback();
|
||||
}
|
||||
},
|
||||
error => {
|
||||
errorCallback(error);
|
||||
}
|
||||
);
|
||||
};
|
@ -1,96 +1,162 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { push } from 'react-router-redux';
|
||||
import Helmet from 'react-helmet';
|
||||
import Button from '../Common/Button/Button';
|
||||
import globals from '../../Globals';
|
||||
import { loginClicked, UPDATE_ADMIN_SECRET_INPUT } from '../Main/Actions';
|
||||
import { verifyLogin } from './Actions';
|
||||
import { CLI_CONSOLE_MODE } from '../../constants';
|
||||
import { getAdminSecret } from '../Services/ApiExplorer/ApiRequest/utils';
|
||||
|
||||
class Login extends Component {
|
||||
handleAdminSecret = e => {
|
||||
this.props.dispatch({
|
||||
type: UPDATE_ADMIN_SECRET_INPUT,
|
||||
data: e.target.value,
|
||||
});
|
||||
};
|
||||
const styles = require('./Login.scss');
|
||||
const hasuraLogo = require('./blue-logo.svg');
|
||||
|
||||
loginClicked = () => {
|
||||
this.props.dispatch(loginClicked());
|
||||
};
|
||||
const Login = ({ dispatch }) => {
|
||||
// request state
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
render() {
|
||||
const { loginInProgress, loginError, dispatch } = this.props;
|
||||
// should persist admin secret
|
||||
const [shouldPersist, setShouldPersist] = useState(true);
|
||||
|
||||
const styles = require('./Login.scss');
|
||||
const hasuraLogo = require('./blue-logo.svg');
|
||||
// input handler
|
||||
const [adminSecretInput, setAdminSecretInput] = useState('');
|
||||
|
||||
let loginText = 'Enter';
|
||||
if (loginInProgress) {
|
||||
loginText = (
|
||||
<span>
|
||||
Verifying...
|
||||
<i className="fa fa-spinner fa-spin" aria-hidden="true" />
|
||||
</span>
|
||||
);
|
||||
} else if (loginError) {
|
||||
loginText = 'Error. Try again?';
|
||||
}
|
||||
const getLoginForm = () => {
|
||||
const getLoginButtonText = () => {
|
||||
// login button text
|
||||
let loginText = 'Enter';
|
||||
if (loading) {
|
||||
loginText = (
|
||||
<span>
|
||||
Verifying...
|
||||
<i className="fa fa-spinner fa-spin" aria-hidden="true" />
|
||||
</span>
|
||||
);
|
||||
} else if (error) {
|
||||
loginText = 'Error. Try again?';
|
||||
}
|
||||
|
||||
return loginText;
|
||||
};
|
||||
|
||||
const toggleShouldPersist = () => setShouldPersist(!shouldPersist);
|
||||
|
||||
const onAdminSecretChange = e => setAdminSecretInput(e.target.value);
|
||||
|
||||
// form submit handler
|
||||
const onSubmit = e => {
|
||||
e.preventDefault();
|
||||
|
||||
const successCallback = () => {
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
dispatch(push(globals.urlPrefix));
|
||||
};
|
||||
|
||||
const errorCallback = err => {
|
||||
setAdminSecretInput('');
|
||||
setLoading(false);
|
||||
setError(err);
|
||||
};
|
||||
|
||||
setLoading(true);
|
||||
|
||||
verifyLogin({
|
||||
adminSecret: adminSecretInput,
|
||||
shouldPersist,
|
||||
successCallback,
|
||||
errorCallback,
|
||||
dispatch,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.mainWrapper + ' container-fluid'}>
|
||||
<div className={styles.container + ' container'} id="login">
|
||||
<div className={styles.loginCenter}>
|
||||
<Helmet title={'Login | ' + 'Hasura'} />
|
||||
<div className={styles.hasuraLogo}>
|
||||
<img src={hasuraLogo} />
|
||||
</div>
|
||||
<div className={styles.loginWrapper}>
|
||||
<form
|
||||
className="form-horizontal"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
dispatch(loginClicked());
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={styles.input_addon_group + ' ' + styles.padd_top}
|
||||
>
|
||||
<div className={'input-group ' + styles.input_group}>
|
||||
<input
|
||||
onChange={this.handleAdminSecret}
|
||||
className={styles.form_input + ' form-control'}
|
||||
type="password"
|
||||
placeholder={`Enter ${globals.adminSecretLabel}`}
|
||||
name="password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.signin_btn}>
|
||||
<Button type="submit" color="green" className="form-control">
|
||||
{loginText}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form className="form-horizontal" onSubmit={onSubmit}>
|
||||
<div className={styles.input_addon_group + ' ' + styles.padd_top}>
|
||||
<div className={'input-group ' + styles.input_group}>
|
||||
<input
|
||||
onChange={onAdminSecretChange}
|
||||
className={styles.form_input + ' form-control'}
|
||||
type="password"
|
||||
placeholder={'Enter admin-secret'}
|
||||
name="password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.login_btn}>
|
||||
<Button
|
||||
type="submit"
|
||||
color="green"
|
||||
className="form-control"
|
||||
disabled={loading}
|
||||
>
|
||||
{getLoginButtonText()}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.add_pad_left}>
|
||||
<label className={`${styles.cursorPointer}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shouldPersist}
|
||||
onChange={toggleShouldPersist}
|
||||
className={`${styles.add_mar_right_small} ${
|
||||
styles.remove_margin_top
|
||||
} ${styles.cursorPointer}`}
|
||||
/>
|
||||
Remember in this browser
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const getCLIAdminSecretErrorMessage = () => {
|
||||
const adminSecret = getAdminSecret();
|
||||
|
||||
const missingAdminSecretMessage = (
|
||||
<span>
|
||||
Seems like your Hasura GraphQL engine instance has an admin-secret
|
||||
configured.
|
||||
<br />
|
||||
Run console with the admin-secret using:
|
||||
<br />
|
||||
<br />
|
||||
hasura console --admin-secret=<your-admin-secret>
|
||||
</span>
|
||||
);
|
||||
|
||||
const invalidAdminSecretMessage = (
|
||||
<span>Invalid admin-secret passed from CLI</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.text_center}>
|
||||
{adminSecret ? invalidAdminSecretMessage : missingAdminSecretMessage}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.mainWrapper + ' container-fluid'}>
|
||||
<div className={styles.container + ' container'} id="login">
|
||||
<div className={styles.loginCenter}>
|
||||
<Helmet title={'Login | ' + 'Hasura'} />
|
||||
<div className={styles.hasuraLogo}>
|
||||
<img src={hasuraLogo} />
|
||||
</div>
|
||||
<div className={styles.loginWrapper}>
|
||||
{globals.consoleMode !== CLI_CONSOLE_MODE
|
||||
? getLoginForm()
|
||||
: getCLIAdminSecretErrorMessage()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Login.propTypes = {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const generatedLoginConnector = connect => {
|
||||
const mapStateToProps = state => {
|
||||
return {
|
||||
loginInProgress: state.main.loginInProgress,
|
||||
loginError: state.main.loginError,
|
||||
adminSecretError: state.tables.adminSecretError,
|
||||
};
|
||||
};
|
||||
return connect(mapStateToProps)(Login);
|
||||
return connect()(Login);
|
||||
};
|
||||
|
||||
export default generatedLoginConnector;
|
||||
|
@ -16,7 +16,7 @@
|
||||
}
|
||||
|
||||
.loginWrapper {
|
||||
width: 350px;
|
||||
width: 400px;
|
||||
background-color: #fff;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
@ -57,7 +57,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.signin_btn {
|
||||
.login_btn {
|
||||
padding: 0 15px;
|
||||
padding-bottom: 15px;
|
||||
padding-top: 10px;
|
||||
|
@ -1,16 +1,8 @@
|
||||
import { push } from 'react-router-redux';
|
||||
import globals from 'Globals';
|
||||
import defaultState from './State';
|
||||
import requestAction from '../../utils/requestAction';
|
||||
import requestActionPlain from '../../utils/requestActionPlain';
|
||||
import Endpoints, { globalCookiePolicy } from '../../Endpoints';
|
||||
import { saveAdminSecretState } from '../AppState';
|
||||
import {
|
||||
ADMIN_SECRET_ERROR,
|
||||
UPDATE_DATA_HEADERS,
|
||||
} from '../Services/Data/DataActions';
|
||||
import { getFeaturesCompatibility } from '../../helpers/versionUtils';
|
||||
import { changeRequestHeader } from '../Services/ApiExplorer/Actions';
|
||||
|
||||
const SET_MIGRATION_STATUS_SUCCESS = 'Main/SET_MIGRATION_STATUS_SUCCESS';
|
||||
const SET_MIGRATION_STATUS_ERROR = 'Main/SET_MIGRATION_STATUS_ERROR';
|
||||
@ -155,77 +147,6 @@ const loadLatestServerVersion = () => (dispatch, getState) => {
|
||||
);
|
||||
};
|
||||
|
||||
const validateLogin = isInitialLoad => (dispatch, getState) => {
|
||||
const url = Endpoints.getSchema;
|
||||
const currentSchema = getState().tables.currentSchema;
|
||||
const options = {
|
||||
credentials: globalCookiePolicy,
|
||||
method: 'POST',
|
||||
headers: getState().tables.dataHeaders,
|
||||
body: JSON.stringify({
|
||||
type: 'select',
|
||||
args: {
|
||||
table: {
|
||||
name: 'hdb_table',
|
||||
schema: 'hdb_catalog',
|
||||
},
|
||||
columns: ['table_schema'],
|
||||
where: { table_schema: currentSchema },
|
||||
limit: 1,
|
||||
},
|
||||
}),
|
||||
};
|
||||
if (isInitialLoad) {
|
||||
return dispatch(requestAction(url, options));
|
||||
}
|
||||
return dispatch(requestAction(url, options)).then(
|
||||
() => {
|
||||
dispatch({ type: LOGIN_IN_PROGRESS, data: false });
|
||||
dispatch({ type: LOGIN_ERROR, data: false });
|
||||
dispatch(push(globals.urlPrefix));
|
||||
},
|
||||
error => {
|
||||
dispatch({ type: LOGIN_IN_PROGRESS, data: false });
|
||||
dispatch({ type: LOGIN_ERROR, data: true });
|
||||
console.error(
|
||||
`Failed to validate ${globals.adminSecretLabel} + JSON.stringify(error)`
|
||||
);
|
||||
if (error.code !== 'access-denied') {
|
||||
alert(JSON.stringify(error));
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const loginClicked = () => (dispatch, getState) => {
|
||||
// set localstorage
|
||||
dispatch({ type: LOGIN_IN_PROGRESS, data: true });
|
||||
const adminSecretInput = getState().main.adminSecretInput;
|
||||
saveAdminSecretState(adminSecretInput);
|
||||
// redirect to / to test the adminSecretInput;
|
||||
const updatedDataHeaders = {
|
||||
'content-type': 'application/json',
|
||||
[`x-hasura-${globals.adminSecretLabel}`]: adminSecretInput,
|
||||
};
|
||||
Promise.all([
|
||||
dispatch({ type: ADMIN_SECRET_ERROR, data: false }),
|
||||
dispatch({ type: UPDATE_DATA_HEADERS, data: updatedDataHeaders }),
|
||||
dispatch(
|
||||
changeRequestHeader(
|
||||
1,
|
||||
'key',
|
||||
`x-hasura-${globals.adminSecretLabel}`,
|
||||
true
|
||||
)
|
||||
),
|
||||
dispatch(changeRequestHeader(1, 'value', adminSecretInput, true)),
|
||||
// dispatch(push('/'))
|
||||
]).then(() => {
|
||||
// make a sample query. check error code and push to /
|
||||
dispatch(validateLogin(false));
|
||||
});
|
||||
};
|
||||
|
||||
const updateMigrationModeStatus = () => (dispatch, getState) => {
|
||||
// make req to hasura cli to update migration mode
|
||||
dispatch({ type: UPDATE_MIGRATION_MODE_PROGRESS, data: true });
|
||||
@ -381,10 +302,8 @@ export {
|
||||
UPDATE_ADMIN_SECRET_INPUT,
|
||||
loadMigrationStatus,
|
||||
updateMigrationModeStatus,
|
||||
loginClicked,
|
||||
LOGIN_IN_PROGRESS,
|
||||
LOGIN_ERROR,
|
||||
validateLogin,
|
||||
loadServerVersion,
|
||||
fetchServerConfig,
|
||||
loadLatestServerVersion,
|
||||
|
@ -23,7 +23,7 @@ import { loadConsoleTelemetryOpts } from '../../telemetry/Actions.js';
|
||||
import {
|
||||
loadInconsistentObjects,
|
||||
redirectToMetadataStatus,
|
||||
} from '../Services/Metadata/Actions';
|
||||
} from '../Services/Settings/Actions';
|
||||
|
||||
import {
|
||||
getLoveConsentState,
|
||||
@ -111,14 +111,17 @@ class Main extends React.Component {
|
||||
}
|
||||
|
||||
handleBodyClick(e) {
|
||||
const heartDropDown = document.getElementById('dropdown_wrapper');
|
||||
const heartDropDownOpen = document.querySelectorAll(
|
||||
'#dropdown_wrapper.open'
|
||||
);
|
||||
|
||||
if (
|
||||
!document.getElementById('dropdown_wrapper').contains(e.target) &&
|
||||
heartDropDown &&
|
||||
!heartDropDown.contains(e.target) &&
|
||||
heartDropDownOpen.length !== 0
|
||||
) {
|
||||
document.getElementById('dropdown_wrapper').classList.remove('open');
|
||||
heartDropDown.classList.remove('open');
|
||||
}
|
||||
}
|
||||
|
||||
@ -193,17 +196,17 @@ class Main extends React.Component {
|
||||
return mainContent;
|
||||
};
|
||||
|
||||
const getMetadataSelectedMarker = () => {
|
||||
const getSettingsSelectedMarker = () => {
|
||||
let metadataSelectedMarker = null;
|
||||
|
||||
if (currentActiveBlock === 'metadata') {
|
||||
if (currentActiveBlock === 'settings') {
|
||||
metadataSelectedMarker = <span className={styles.selected} />;
|
||||
}
|
||||
|
||||
return metadataSelectedMarker;
|
||||
};
|
||||
|
||||
const getMetadataIcon = () => {
|
||||
const getMetadataStatusIcon = () => {
|
||||
if (metadata.inconsistentObjects.length === 0) {
|
||||
return <i className={styles.question + ' fa fa-cog'} />;
|
||||
}
|
||||
@ -223,10 +226,7 @@ class Main extends React.Component {
|
||||
const getAdminSecretSection = () => {
|
||||
let adminSecretHtml = null;
|
||||
|
||||
if (
|
||||
!globals.isAdminSecretSet &&
|
||||
(globals.adminSecret === '' || globals.adminSecret === null)
|
||||
) {
|
||||
if (!(globals.isAdminSecretSet || globals.adminSecret)) {
|
||||
adminSecretHtml = (
|
||||
<div className={styles.secureSection}>
|
||||
<a
|
||||
@ -430,7 +430,7 @@ class Main extends React.Component {
|
||||
path,
|
||||
isDefault = false
|
||||
) => {
|
||||
const itemTooltip = <Tooltip>{tooltipText}</Tooltip>;
|
||||
const itemTooltip = <Tooltip id={tooltipText}>{tooltipText}</Tooltip>;
|
||||
|
||||
const block = getPathRoot(path);
|
||||
|
||||
@ -504,10 +504,10 @@ class Main extends React.Component {
|
||||
<div id="dropdown_wrapper" className={styles.clusterInfoWrapper}>
|
||||
{getAdminSecretSection()}
|
||||
|
||||
<Link to="/metadata">
|
||||
<Link to="/settings">
|
||||
<div className={styles.helpSection + ' ' + styles.settingsIcon}>
|
||||
{getMetadataIcon()}
|
||||
{getMetadataSelectedMarker()}
|
||||
{getMetadataStatusIcon()}
|
||||
{getSettingsSelectedMarker()}
|
||||
</div>
|
||||
</Link>
|
||||
<div className={styles.supportSection}>
|
||||
|
@ -1,5 +1,3 @@
|
||||
import globals from '../../Globals';
|
||||
|
||||
export const data = 'Data & Schema management';
|
||||
|
||||
export const apiExplorer = 'Test the GraphQL APIs';
|
||||
@ -8,8 +6,5 @@ export const events = 'Manage Event Triggers';
|
||||
|
||||
export const remoteSchema = 'Manage Remote Schemas';
|
||||
|
||||
export const roles = 'User Roles Summary';
|
||||
|
||||
export const secureEndpoint = `This GraphQL endpoint is public. You should add an ${
|
||||
globals.adminSecretLabel
|
||||
}`;
|
||||
export const secureEndpoint =
|
||||
'This GraphQL endpoint is public. You should add an admin-secret';
|
||||
|
@ -11,6 +11,7 @@ import { execute } from 'apollo-link';
|
||||
|
||||
import { getHeadersAsJSON } from './utils';
|
||||
import { saveAppState, clearState } from '../../AppState.js';
|
||||
import { ADMIN_SECRET_HEADER_KEY } from '../../../constants';
|
||||
|
||||
const CHANGE_TAB = 'ApiExplorer/CHANGE_TAB';
|
||||
const CHANGE_API_SELECTION = 'ApiExplorer/CHANGE_API_SELECTION';
|
||||
@ -28,7 +29,7 @@ const REQUEST_PARAMS_CHANGED = 'ApiExplorer/REQUEST_PARAMS_CHANGED';
|
||||
const REQUEST_HEADER_CHANGED = 'ApiExplorer/REQUEST_HEADER_CHANGED';
|
||||
const REQUEST_HEADER_ADDED = 'ApiExplorer/REQUEST_HEADER_ADDED';
|
||||
const REQUEST_HEADER_REMOVED = 'ApiExplorer/REQUEST_HEADER_REMOVED';
|
||||
const SET_INITIAL_HEADER_DATA = 'ApiExplorer/SET_INITIAL_HEADER_DATA';
|
||||
const SET_REQUEST_HEADERS_BULK = 'ApiExplorer/SET_REQUEST_HEADERS_BULK';
|
||||
|
||||
const MAKING_API_REQUEST = 'ApiExplorer/MAKING_API_REQUEST';
|
||||
const RESET_MAKING_REQUEST = 'ApiExplorer/RESET_MAKING_REQUEST';
|
||||
@ -217,8 +218,7 @@ const analyzeFetcher = (url, headers) => {
|
||||
const lHead = t.toLowerCase();
|
||||
if (
|
||||
lHead.slice(0, 'x-hasura-'.length) === 'x-hasura-' &&
|
||||
lHead !== 'x-hasura-access-key' &&
|
||||
lHead !== 'x-hasura-admin-secret'
|
||||
lHead !== ADMIN_SECRET_HEADER_KEY
|
||||
) {
|
||||
user[lHead] = reqHeaders[t];
|
||||
delete reqHeaders[t];
|
||||
@ -237,13 +237,6 @@ const analyzeFetcher = (url, headers) => {
|
||||
};
|
||||
/* End of it */
|
||||
|
||||
const setInitialHeaderState = headerObj => {
|
||||
return {
|
||||
type: SET_INITIAL_HEADER_DATA,
|
||||
data: headerObj,
|
||||
};
|
||||
};
|
||||
|
||||
const changeRequestHeader = (index, key, newValue, isDisabled) => {
|
||||
return (dispatch, getState) => {
|
||||
const currentState = getState().apiexplorer;
|
||||
@ -269,6 +262,8 @@ const changeRequestHeader = (index, key, newValue, isDisabled) => {
|
||||
};
|
||||
};
|
||||
|
||||
const setHeadersBulk = headers => ({ type: SET_REQUEST_HEADERS_BULK, headers });
|
||||
|
||||
const removeRequestHeader = index => {
|
||||
return (dispatch, getState) => {
|
||||
const currentState = getState().apiexplorer;
|
||||
@ -513,18 +508,6 @@ const apiExplorerReducer = (state = defaultState, action) => {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
case SET_INITIAL_HEADER_DATA:
|
||||
return {
|
||||
...state,
|
||||
displayedApi: {
|
||||
...state.displayedApi,
|
||||
request: {
|
||||
...state.displayedApi.request,
|
||||
headers: [...action.data],
|
||||
},
|
||||
},
|
||||
};
|
||||
case REQUEST_HEADER_ADDED:
|
||||
return {
|
||||
...state,
|
||||
@ -621,6 +604,18 @@ const apiExplorerReducer = (state = defaultState, action) => {
|
||||
...state,
|
||||
headerFocus: true,
|
||||
};
|
||||
case SET_REQUEST_HEADERS_BULK:
|
||||
return {
|
||||
...state,
|
||||
displayedApi: {
|
||||
...state.displayedApi,
|
||||
request: {
|
||||
...state.displayedApi.request,
|
||||
headers: action.headers,
|
||||
headersInitialised: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
@ -651,6 +646,6 @@ export {
|
||||
unfocusTypingHeader,
|
||||
getRemoteQueries,
|
||||
analyzeFetcher,
|
||||
setInitialHeaderState,
|
||||
verifyJWTToken,
|
||||
setHeadersBulk,
|
||||
};
|
||||
|
@ -107,4 +107,19 @@ ApiExplorer.propTypes = {
|
||||
location: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default ApiExplorer;
|
||||
const generatedApiExplorer = connect => {
|
||||
const mapStateToProps = state => {
|
||||
return {
|
||||
...state.apiexplorer,
|
||||
serverVersion: state.main.serverVersion ? state.main.serverVersion : '',
|
||||
credentials: {},
|
||||
dataApiExplorerData: { ...state.dataApiExplorer },
|
||||
dataHeaders: state.tables.dataHeaders,
|
||||
tables: state.tables.allSchemas,
|
||||
serverConfig: state.main.serverConfig ? state.main.serverConfig.data : {},
|
||||
};
|
||||
};
|
||||
return connect(mapStateToProps)(ApiExplorer);
|
||||
};
|
||||
|
||||
export default generatedApiExplorer;
|
||||
|
@ -1,18 +0,0 @@
|
||||
import ApiExplorer from './ApiExplorer';
|
||||
|
||||
const generatedApiExplorer = connect => {
|
||||
const mapStateToProps = state => {
|
||||
return {
|
||||
...state.apiexplorer,
|
||||
serverVersion: state.main.serverVersion ? state.main.serverVersion : '',
|
||||
credentials: {},
|
||||
dataApiExplorerData: { ...state.dataApiExplorer },
|
||||
dataHeaders: state.tables.dataHeaders,
|
||||
tables: state.tables.allSchemas,
|
||||
serverConfig: state.main.serverConfig ? state.main.serverConfig.data : {},
|
||||
};
|
||||
};
|
||||
return connect(mapStateToProps)(ApiExplorer);
|
||||
};
|
||||
|
||||
export default generatedApiExplorer;
|
@ -8,19 +8,15 @@ import OverlayTrigger from 'react-bootstrap/lib/OverlayTrigger';
|
||||
import Tooltip from 'react-bootstrap/lib/Tooltip';
|
||||
import Modal from '../../../Common/Modal/Modal';
|
||||
|
||||
import { parseAuthHeader } from './utils';
|
||||
|
||||
import {
|
||||
changeRequestHeader,
|
||||
removeRequestHeader,
|
||||
focusHeaderTextbox,
|
||||
unfocusTypingHeader,
|
||||
setInitialHeaderState,
|
||||
verifyJWTToken,
|
||||
setHeadersBulk,
|
||||
} from '../Actions';
|
||||
|
||||
import globals from '../../../../Globals';
|
||||
|
||||
import GraphiQLWrapper from '../GraphiQLWrapper/GraphiQLWrapper';
|
||||
|
||||
import CollapsibleToggle from '../../../Common/CollapsibleToggle/CollapsibleToggle';
|
||||
@ -30,11 +26,17 @@ import {
|
||||
setEndPointSectionIsOpen,
|
||||
getHeadersSectionIsOpen,
|
||||
setHeadersSectionIsOpen,
|
||||
getGraphiQLHeadersFromLocalStorage,
|
||||
setGraphiQLHeadersInLocalStorage,
|
||||
persistGraphiQLHeaders,
|
||||
getPersistedGraphiQLHeaders,
|
||||
parseAuthHeader,
|
||||
getDefaultGraphiqlHeaders,
|
||||
getAdminSecret,
|
||||
getPersistedAdminSecretHeaderWasAdded,
|
||||
persistAdminSecretHeaderWasAdded,
|
||||
} from './utils';
|
||||
|
||||
import styles from '../ApiExplorer.scss';
|
||||
import { ADMIN_SECRET_HEADER_KEY } from '../../../../constants';
|
||||
|
||||
const inspectJWTTooltip = (
|
||||
<Tooltip id="tooltip-inspect-jwt">Decode JWT</Tooltip>
|
||||
@ -78,19 +80,47 @@ class ApiRequest extends Component {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { headers } = this.props;
|
||||
const HEADER_FROM_LS = getGraphiQLHeadersFromLocalStorage();
|
||||
if (HEADER_FROM_LS) {
|
||||
try {
|
||||
const initialHeader = JSON.parse(HEADER_FROM_LS);
|
||||
this.props.dispatch(setInitialHeaderState(initialHeader));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setGraphiQLHeadersInLocalStorage(JSON.stringify(headers));
|
||||
const handleHeaderInit = () => {
|
||||
const graphiqlHeaders =
|
||||
getPersistedGraphiQLHeaders() || getDefaultGraphiqlHeaders();
|
||||
|
||||
// if admin secret is set and admin secret header was ever added to headers, add admin secret header if not already present
|
||||
const adminSecret = getAdminSecret();
|
||||
const adminSecretHeaderWasAdded = getPersistedAdminSecretHeaderWasAdded();
|
||||
if (adminSecret && !adminSecretHeaderWasAdded) {
|
||||
const headerKeys = graphiqlHeaders.map(h => h.key);
|
||||
|
||||
if (!headerKeys.includes(ADMIN_SECRET_HEADER_KEY)) {
|
||||
graphiqlHeaders.push({
|
||||
key: ADMIN_SECRET_HEADER_KEY,
|
||||
value: adminSecret,
|
||||
isActive: true,
|
||||
isNewHeader: false,
|
||||
isDisabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
// set in local storage that admin secret header has been automatically added
|
||||
persistAdminSecretHeaderWasAdded();
|
||||
}
|
||||
} else {
|
||||
setGraphiQLHeadersInLocalStorage(JSON.stringify(headers));
|
||||
}
|
||||
|
||||
// add an empty placeholder header
|
||||
graphiqlHeaders.push({
|
||||
key: '',
|
||||
value: '',
|
||||
isActive: true,
|
||||
isNewHeader: true,
|
||||
isDisabled: false,
|
||||
});
|
||||
|
||||
// persist headers to local storage
|
||||
persistGraphiQLHeaders(graphiqlHeaders);
|
||||
|
||||
// set headers in redux
|
||||
this.props.dispatch(setHeadersBulk(graphiqlHeaders));
|
||||
};
|
||||
|
||||
handleHeaderInit();
|
||||
}
|
||||
|
||||
onAnalyzeBearerClose() {
|
||||
@ -242,7 +272,7 @@ class ApiRequest extends Component {
|
||||
const index = parseInt(e.target.getAttribute('data-header-id'), 10);
|
||||
this.props
|
||||
.dispatch(removeRequestHeader(index))
|
||||
.then(r => setGraphiQLHeadersInLocalStorage(JSON.stringify(r)));
|
||||
.then(r => persistGraphiQLHeaders(r));
|
||||
};
|
||||
|
||||
const onHeaderValueChanged = e => {
|
||||
@ -251,7 +281,7 @@ class ApiRequest extends Component {
|
||||
const newValue = e.target.value;
|
||||
this.props
|
||||
.dispatch(changeRequestHeader(index, key, newValue, false))
|
||||
.then(r => setGraphiQLHeadersInLocalStorage(JSON.stringify(r)));
|
||||
.then(r => persistGraphiQLHeaders(r));
|
||||
};
|
||||
|
||||
const onShowAdminSecretClicked = () => {
|
||||
@ -260,7 +290,7 @@ class ApiRequest extends Component {
|
||||
|
||||
return headers.map((header, i) => {
|
||||
const isAdminSecret =
|
||||
header.key.toLowerCase() === `x-hasura-${globals.adminSecretLabel}`;
|
||||
header.key.toLowerCase() === ADMIN_SECRET_HEADER_KEY;
|
||||
|
||||
const getHeaderActiveCheckBox = () => {
|
||||
let headerActiveCheckbox = null;
|
||||
@ -401,7 +431,7 @@ class ApiRequest extends Component {
|
||||
} else {
|
||||
analyzeIcon = (
|
||||
<i
|
||||
className={styles.showInspector + ' fa fa-plus-square-o'}
|
||||
className={styles.showInspector + ' fa fa-user-secret'}
|
||||
token={token}
|
||||
data-header-index={i}
|
||||
onClick={this.analyzeBearerToken}
|
||||
@ -613,8 +643,8 @@ class ApiRequest extends Component {
|
||||
claimData =
|
||||
claimFormat === 'stringified_json'
|
||||
? generateValidNameSpaceData(
|
||||
JSON.parse(payload[claimNameSpace])
|
||||
)
|
||||
JSON.parse(payload[claimNameSpace])
|
||||
)
|
||||
: generateValidNameSpaceData(payload[claimNameSpace]);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@ -712,6 +742,7 @@ ApiRequest.propTypes = {
|
||||
method: PropTypes.string.isRequired,
|
||||
url: PropTypes.string.isRequired,
|
||||
headers: PropTypes.array,
|
||||
dataHeaders: PropTypes.array,
|
||||
params: PropTypes.string,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
explorerData: PropTypes.object.isRequired,
|
||||
|
@ -1,3 +1,10 @@
|
||||
import globals from '../../../../Globals';
|
||||
import { loadAdminSecretState } from '../../../AppState';
|
||||
import {
|
||||
ADMIN_SECRET_HEADER_KEY,
|
||||
SERVER_CONSOLE_MODE,
|
||||
} from '../../../../constants';
|
||||
|
||||
export const setEndPointSectionIsOpen = isOpen => {
|
||||
window.localStorage.setItem('ApiExplorer:EndpointSectionIsOpen', isOpen);
|
||||
};
|
||||
@ -26,12 +33,95 @@ export const getHeadersSectionIsOpen = () => {
|
||||
return isOpen ? isOpen === 'true' : defaultIsOpen;
|
||||
};
|
||||
|
||||
export const setGraphiQLHeadersInLocalStorage = headers => {
|
||||
window.localStorage.setItem('HASURA_CONSOLE_GRAPHIQL_HEADERS', headers);
|
||||
export const getAdminSecret = () => {
|
||||
let adminSecret = null;
|
||||
if (globals.consoleMode === SERVER_CONSOLE_MODE && globals.isAdminSecretSet) {
|
||||
const adminSecretFromLS = loadAdminSecretState();
|
||||
const adminSecretInGlobals = globals.adminSecret;
|
||||
|
||||
adminSecret = adminSecretFromLS || adminSecretInGlobals;
|
||||
} else {
|
||||
adminSecret = globals.adminSecret;
|
||||
}
|
||||
|
||||
return adminSecret;
|
||||
};
|
||||
|
||||
export const getGraphiQLHeadersFromLocalStorage = () => {
|
||||
return window.localStorage.getItem('HASURA_CONSOLE_GRAPHIQL_HEADERS');
|
||||
export const persistAdminSecretHeaderWasAdded = () => {
|
||||
window.localStorage.setItem('ApiExplorer:AdminSecretHeaderWasAdded', true);
|
||||
};
|
||||
|
||||
export const getPersistedAdminSecretHeaderWasAdded = () => {
|
||||
const defaultIsSet = false;
|
||||
|
||||
const isSet = window.localStorage.getItem(
|
||||
'ApiExplorer:AdminSecretHeaderWasAdded'
|
||||
);
|
||||
|
||||
return isSet ? isSet === 'true' : defaultIsSet;
|
||||
};
|
||||
|
||||
export const persistGraphiQLHeaders = headers => {
|
||||
// filter empty headers
|
||||
const validHeaders = headers.filter(h => h.key);
|
||||
|
||||
// remove admin-secret value
|
||||
const maskedHeaders = validHeaders.map(h => {
|
||||
const maskedHeader = { ...h };
|
||||
|
||||
if (h.key.toLowerCase() === ADMIN_SECRET_HEADER_KEY) {
|
||||
maskedHeader.value = 'xxx';
|
||||
}
|
||||
|
||||
return maskedHeader;
|
||||
});
|
||||
|
||||
window.localStorage.setItem(
|
||||
'HASURA_CONSOLE_GRAPHIQL_HEADERS',
|
||||
JSON.stringify(maskedHeaders)
|
||||
);
|
||||
};
|
||||
|
||||
export const getPersistedGraphiQLHeaders = () => {
|
||||
const headersString = window.localStorage.getItem(
|
||||
'HASURA_CONSOLE_GRAPHIQL_HEADERS'
|
||||
);
|
||||
|
||||
let headers = null;
|
||||
if (headersString) {
|
||||
try {
|
||||
headers = JSON.parse(headersString);
|
||||
|
||||
// add admin-secret value
|
||||
headers = headers.map(h => {
|
||||
const unmaskedHeader = { ...h };
|
||||
|
||||
if (h.key.toLowerCase() === ADMIN_SECRET_HEADER_KEY) {
|
||||
unmaskedHeader.value = getAdminSecret();
|
||||
}
|
||||
|
||||
return unmaskedHeader;
|
||||
});
|
||||
} catch (_) {
|
||||
console.error('Failed parsing headers from local storage');
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
};
|
||||
|
||||
export const getDefaultGraphiqlHeaders = () => {
|
||||
const headers = [];
|
||||
|
||||
headers.push({
|
||||
key: 'content-type',
|
||||
value: 'application/json',
|
||||
isActive: true,
|
||||
isNewHeader: false,
|
||||
isDisabled: false,
|
||||
});
|
||||
|
||||
return headers;
|
||||
};
|
||||
|
||||
export const parseAuthHeader = header => {
|
||||
|
@ -61,6 +61,7 @@ class ApiRequestWrapper extends Component {
|
||||
method={this.props.request.method}
|
||||
url={this.props.request.url}
|
||||
headers={this.props.request.headers}
|
||||
headersInitialised={this.props.request.headersInitialised}
|
||||
params={this.props.request.params}
|
||||
explorerData={this.props.explorerData}
|
||||
dispatch={this.props.dispatch}
|
||||
|
@ -33,7 +33,6 @@ class GraphiQLWrapper extends Component {
|
||||
|
||||
const { numberOfTables, urlParams, headerFocus } = this.props;
|
||||
const graphqlNetworkData = this.props.data;
|
||||
|
||||
const graphQLFetcher = graphQLParams => {
|
||||
if (headerFocus) {
|
||||
return null;
|
||||
@ -78,6 +77,7 @@ class GraphiQLWrapper extends Component {
|
||||
renderGraphiql={renderGraphiql}
|
||||
endpoint={graphqlNetworkData.url}
|
||||
headers={graphqlNetworkData.headers}
|
||||
headersInitialised={graphqlNetworkData.headersInitialised}
|
||||
headerFocus={headerFocus}
|
||||
urlParams={urlParams}
|
||||
numberOfTables={numberOfTables}
|
||||
|
@ -78,7 +78,10 @@ class OneGraphExplorer extends React.Component {
|
||||
}
|
||||
|
||||
introspect() {
|
||||
const { endpoint } = this.props;
|
||||
const { endpoint, headersInitialised } = this.props;
|
||||
if (!headersInitialised) {
|
||||
return;
|
||||
}
|
||||
const headers = JSON.parse(JSON.stringify(this.props.headers));
|
||||
this.setState({ loading: true });
|
||||
fetch(endpoint, {
|
||||
|
@ -34,6 +34,7 @@ dataApisContent.push({
|
||||
method: 'POST',
|
||||
url: getUrl('/v1/graphql'),
|
||||
headers: defaultHeader,
|
||||
headersInitialised: false,
|
||||
bodyType: 'graphql',
|
||||
params: JSON.stringify({}, null, 4),
|
||||
},
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import ReloadEnumMetadata from '../../../Metadata/MetadataOptions/ReloadMetadata';
|
||||
import ReloadEnumMetadata from '../../../Settings/MetadataOptions/ReloadMetadata';
|
||||
import OverlayTrigger from 'react-bootstrap/lib/OverlayTrigger';
|
||||
import Tooltip from 'react-bootstrap/lib/Tooltip';
|
||||
import styles from '../../../../Common/Common.scss';
|
||||
|
@ -1,10 +1,10 @@
|
||||
import Endpoints from '../../../../Endpoints';
|
||||
import globals from '../../../../Globals';
|
||||
|
||||
import { SERVER_CONSOLE_MODE } from '../../../../constants';
|
||||
import { CLI_CONSOLE_MODE, SERVER_CONSOLE_MODE } from '../../../../constants';
|
||||
|
||||
const returnMigrateUrl = mode => {
|
||||
if (globals.consoleMode === 'cli') {
|
||||
if (globals.consoleMode === CLI_CONSOLE_MODE) {
|
||||
return mode ? Endpoints.hasuractlMigrate : Endpoints.hasuractlMetadata;
|
||||
} else if (globals.consoleMode === SERVER_CONSOLE_MODE) {
|
||||
return Endpoints.query;
|
||||
|
@ -16,8 +16,8 @@ import {
|
||||
import dataHeaders from './Common/Headers';
|
||||
import { loadMigrationStatus } from '../../Main/Actions';
|
||||
import returnMigrateUrl from './Common/getMigrateUrl';
|
||||
import { loadInconsistentObjects } from '../Metadata/Actions';
|
||||
import { filterInconsistentMetadataObjects } from '../Metadata/utils';
|
||||
import { loadInconsistentObjects } from '../Settings/Actions';
|
||||
import { filterInconsistentMetadataObjects } from '../Settings/utils';
|
||||
import globals from '../../../Globals';
|
||||
|
||||
import {
|
||||
@ -32,7 +32,7 @@ import { fetchColumnTypesQuery, fetchColumnDefaultFunctions } from './utils';
|
||||
|
||||
import { fetchColumnCastsQuery, convertArrayToJson } from './TableModify/utils';
|
||||
|
||||
import { SERVER_CONSOLE_MODE } from '../../../constants';
|
||||
import { CLI_CONSOLE_MODE, SERVER_CONSOLE_MODE } from '../../../constants';
|
||||
|
||||
const SET_TABLE = 'Data/SET_TABLE';
|
||||
const LOAD_FUNCTIONS = 'Data/LOAD_FUNCTIONS';
|
||||
@ -43,11 +43,12 @@ const LOAD_UNTRACKED_RELATIONS = 'Data/LOAD_UNTRACKED_RELATIONS';
|
||||
const FETCH_SCHEMA_LIST = 'Data/FETCH_SCHEMA_LIST';
|
||||
const UPDATE_CURRENT_SCHEMA = 'Data/UPDATE_CURRENT_SCHEMA';
|
||||
const ADMIN_SECRET_ERROR = 'Data/ADMIN_SECRET_ERROR';
|
||||
const UPDATE_DATA_HEADERS = 'Data/UPDATE_DATA_HEADERS';
|
||||
const UPDATE_REMOTE_SCHEMA_MANUAL_REL = 'Data/UPDATE_SCHEMA_MANUAL_REL';
|
||||
const SET_CONSISTENT_SCHEMA = 'Data/SET_CONSISTENT_SCHEMA';
|
||||
const SET_CONSISTENT_FUNCTIONS = 'Data/SET_CONSISTENT_FUNCTIONS';
|
||||
|
||||
const UPDATE_DATA_HEADERS = 'Data/UPDATE_DATA_HEADERS';
|
||||
|
||||
const FETCH_COLUMN_TYPE_INFO = 'Data/FETCH_COLUMN_TYPE_INFO';
|
||||
const FETCH_COLUMN_TYPE_INFO_FAIL = 'Data/FETCH_COLUMN_TYPE_INFO_FAIL';
|
||||
const RESET_COLUMN_TYPE_INFO = 'Data/RESET_COLUMN_TYPE_INFO';
|
||||
@ -491,7 +492,7 @@ const makeMigrationCall = (
|
||||
let finalReqBody;
|
||||
if (globals.consoleMode === SERVER_CONSOLE_MODE) {
|
||||
finalReqBody = upQuery;
|
||||
} else if (globals.consoleMode === 'cli') {
|
||||
} else if (globals.consoleMode === CLI_CONSOLE_MODE) {
|
||||
finalReqBody = migrationBody;
|
||||
}
|
||||
const url = migrateUrl;
|
||||
@ -504,7 +505,7 @@ const makeMigrationCall = (
|
||||
|
||||
const onSuccess = data => {
|
||||
if (!shouldSkipSchemaReload) {
|
||||
if (globals.consoleMode === 'cli') {
|
||||
if (globals.consoleMode === CLI_CONSOLE_MODE) {
|
||||
dispatch(loadMigrationStatus()); // don't call for server mode
|
||||
}
|
||||
dispatch(updateSchemaInfo());
|
||||
|
@ -8,6 +8,7 @@ import DataSubSidebar from './DataSubSidebar';
|
||||
|
||||
import { updateCurrentSchema } from './DataActions';
|
||||
import { NotFoundError } from '../../Error/PageNotFound';
|
||||
import { CLI_CONSOLE_MODE } from '../../../constants';
|
||||
|
||||
const sectionPrefix = '/data';
|
||||
|
||||
@ -30,7 +31,7 @@ const DataPageContainer = ({
|
||||
const currentLocation = location.pathname;
|
||||
|
||||
let migrationTab = null;
|
||||
if (globals.consoleMode === 'cli') {
|
||||
if (globals.consoleMode === CLI_CONSOLE_MODE) {
|
||||
migrationTab = (
|
||||
<li
|
||||
role="presentation"
|
||||
|
@ -34,7 +34,6 @@ import {
|
||||
fetchFunctionInit,
|
||||
UPDATE_CURRENT_SCHEMA,
|
||||
updateSchemaInfo,
|
||||
// UPDATE_DATA_HEADERS,
|
||||
// ADMIN_SECRET_ERROR,
|
||||
} from './DataActions';
|
||||
|
||||
|
@ -10,7 +10,7 @@ import dataHeaders from '../Common/Headers';
|
||||
import globals from '../../../../Globals';
|
||||
|
||||
import returnMigrateUrl from '../Common/getMigrateUrl';
|
||||
import { SERVER_CONSOLE_MODE } from '../../../../constants';
|
||||
import { CLI_CONSOLE_MODE, SERVER_CONSOLE_MODE } from '../../../../constants';
|
||||
import { loadMigrationStatus } from '../../../Main/Actions';
|
||||
import { handleMigrationErrors } from '../../EventTrigger/EventActions';
|
||||
|
||||
@ -73,7 +73,7 @@ const makeRequest = (
|
||||
let finalReqBody;
|
||||
if (globals.consoleMode === SERVER_CONSOLE_MODE) {
|
||||
finalReqBody = upQuery;
|
||||
} else if (globals.consoleMode === 'cli') {
|
||||
} else if (globals.consoleMode === CLI_CONSOLE_MODE) {
|
||||
finalReqBody = migrationBody;
|
||||
}
|
||||
const url = migrateUrl;
|
||||
@ -85,7 +85,7 @@ const makeRequest = (
|
||||
};
|
||||
|
||||
const onSuccess = data => {
|
||||
if (globals.consoleMode === 'cli') {
|
||||
if (globals.consoleMode === CLI_CONSOLE_MODE) {
|
||||
dispatch(loadMigrationStatus()); // don't call for server mode
|
||||
}
|
||||
if (successMsg) {
|
||||
|
@ -19,6 +19,7 @@ import {
|
||||
import { modalOpen, modalClose } from './Actions';
|
||||
import globals from '../../../../Globals';
|
||||
import './AceEditorFix.css';
|
||||
import { CLI_CONSOLE_MODE } from '../../../../constants';
|
||||
|
||||
const RawSQL = ({
|
||||
sql,
|
||||
@ -81,7 +82,7 @@ const RawSQL = ({
|
||||
if (isMigration && migrationName.length === 0) {
|
||||
migrationName = 'run_sql_migration';
|
||||
}
|
||||
if (!isMigration && globals.consoleMode === 'cli') {
|
||||
if (!isMigration && globals.consoleMode === CLI_CONSOLE_MODE) {
|
||||
// if migration is not checked, check if is schema modification
|
||||
if (isSchemaModification(sql)) {
|
||||
dispatch(modalOpen());
|
||||
@ -435,7 +436,7 @@ const RawSQL = ({
|
||||
return migrationNameSection;
|
||||
};
|
||||
|
||||
if (migrationMode && globals.consoleMode === 'cli') {
|
||||
if (migrationMode && globals.consoleMode === CLI_CONSOLE_MODE) {
|
||||
migrationSection = (
|
||||
<div className={styles.add_mar_top_small}>
|
||||
{getIsMigrationSection()}
|
||||
|
@ -36,6 +36,7 @@ import {
|
||||
getCreatePkSql,
|
||||
getDropPkSql,
|
||||
} from './utils';
|
||||
import { CLI_CONSOLE_MODE } from '../../../../constants';
|
||||
|
||||
const DELETE_PK_WARNING =
|
||||
'Without a primary key there is no way to uniquely identify a row of a table';
|
||||
@ -1047,7 +1048,7 @@ const deleteColumnSql = (column, tableSchema) => {
|
||||
const errorMsg = 'Deleting column failed';
|
||||
|
||||
const customOnSuccess = (data, consoleMode, migrationMode) => {
|
||||
if (consoleMode === 'cli' && migrationMode) {
|
||||
if (consoleMode === CLI_CONSOLE_MODE && migrationMode) {
|
||||
// show warning information
|
||||
dispatch(
|
||||
showWarningNotification(
|
||||
|
@ -1,10 +1,10 @@
|
||||
import Endpoints from '../../../../Endpoints';
|
||||
import globals from '../../../../Globals';
|
||||
|
||||
import { SERVER_CONSOLE_MODE } from '../../../../constants';
|
||||
import { CLI_CONSOLE_MODE, SERVER_CONSOLE_MODE } from '../../../../constants';
|
||||
|
||||
const returnMigrateUrl = mode => {
|
||||
if (globals.consoleMode === 'cli') {
|
||||
if (globals.consoleMode === CLI_CONSOLE_MODE) {
|
||||
return mode ? Endpoints.hasuractlMigrate : Endpoints.hasuractlMetadata;
|
||||
} else if (globals.consoleMode === SERVER_CONSOLE_MODE) {
|
||||
return Endpoints.query;
|
||||
|
@ -14,12 +14,12 @@ import { loadMigrationStatus } from '../../Main/Actions';
|
||||
import returnMigrateUrl from './Common/getMigrateUrl';
|
||||
import globals from '../../../Globals';
|
||||
import push from './push';
|
||||
import { loadInconsistentObjects } from '../Metadata/Actions';
|
||||
import { filterInconsistentMetadataObjects } from '../Metadata/utils';
|
||||
import { loadInconsistentObjects } from '../Settings/Actions';
|
||||
import { filterInconsistentMetadataObjects } from '../Settings/utils';
|
||||
import { replace } from 'react-router-redux';
|
||||
import { getEventTriggersQuery } from './utils';
|
||||
|
||||
import { SERVER_CONSOLE_MODE } from '../../../constants';
|
||||
import { CLI_CONSOLE_MODE, SERVER_CONSOLE_MODE } from '../../../constants';
|
||||
import { REQUEST_COMPLETE, REQUEST_ONGOING } from './Modify/Actions';
|
||||
|
||||
const SET_TRIGGER = 'Event/SET_TRIGGER';
|
||||
@ -372,7 +372,7 @@ const makeMigrationCall = (
|
||||
let finalReqBody;
|
||||
if (globals.consoleMode === SERVER_CONSOLE_MODE) {
|
||||
finalReqBody = upQuery;
|
||||
} else if (globals.consoleMode === 'cli') {
|
||||
} else if (globals.consoleMode === CLI_CONSOLE_MODE) {
|
||||
finalReqBody = migrationBody;
|
||||
}
|
||||
const url = migrateUrl;
|
||||
@ -384,7 +384,7 @@ const makeMigrationCall = (
|
||||
};
|
||||
|
||||
const onSuccess = () => {
|
||||
if (globals.consoleMode === 'cli') {
|
||||
if (globals.consoleMode === CLI_CONSOLE_MODE) {
|
||||
dispatch(loadMigrationStatus()); // don't call for server mode
|
||||
}
|
||||
customOnSuccess();
|
||||
|
@ -1 +0,0 @@
|
||||
@import "../Metadata.scss";
|
@ -1,69 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { clearAdminSecretState, CONSOLE_ADMIN_SECRET } from '../../../AppState';
|
||||
import globals from '../../../../Globals';
|
||||
|
||||
import {
|
||||
showSuccessNotification,
|
||||
showErrorNotification,
|
||||
} from '../../Common/Notification';
|
||||
import Button from '../../../Common/Button/Button';
|
||||
|
||||
class ClearAdminSecret extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {};
|
||||
this.state.isClearing = false;
|
||||
}
|
||||
render() {
|
||||
const metaDataStyles = require('../Metadata.scss');
|
||||
return (
|
||||
<div className={metaDataStyles.display_inline}>
|
||||
<Button
|
||||
data-test="data-clear-access-key"
|
||||
className={metaDataStyles.margin_right}
|
||||
color="white"
|
||||
size="sm"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
this.setState({ isClearing: true });
|
||||
if (globals.isAdminSecretSet || globals.adminSecret) {
|
||||
clearAdminSecretState();
|
||||
this.props.dispatch(
|
||||
showSuccessNotification(`Cleared ${globals.adminSecretLabel}`)
|
||||
);
|
||||
this.setState({ isClearing: false });
|
||||
this.props.router.push('/login');
|
||||
} else {
|
||||
this.setState({ isClearing: false });
|
||||
const errorMessage = (
|
||||
<div style={{ padding: '5px' }}>
|
||||
<div style={{ fontSize: '13px' }}>
|
||||
No {globals.adminSecretLabel} set
|
||||
</div>
|
||||
<br />
|
||||
<div style={{ fontSize: '13px' }}>
|
||||
Please look for <code>{CONSOLE_ADMIN_SECRET}</code> key
|
||||
under window storage and delete it if it exists
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
this.props.dispatch(showErrorNotification(errorMessage));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{this.state.isClearing
|
||||
? 'Clearing...'
|
||||
: `Clear ${globals.adminSecretLabel} (logout)`}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ClearAdminSecret.propTypes = {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
dataHeaders: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default ClearAdminSecret;
|
@ -7,12 +7,12 @@ import requestAction from '../../../utils/requestAction';
|
||||
import dataHeaders from '../Data/Common/Headers';
|
||||
import globals from '../../../Globals';
|
||||
import returnMigrateUrl from '../Data/Common/getMigrateUrl';
|
||||
import { SERVER_CONSOLE_MODE } from '../../../constants';
|
||||
import { CLI_CONSOLE_MODE, SERVER_CONSOLE_MODE } from '../../../constants';
|
||||
import { loadMigrationStatus } from '../../Main/Actions';
|
||||
import { handleMigrationErrors } from '../EventTrigger/EventActions';
|
||||
|
||||
import { showSuccessNotification } from '../Common/Notification';
|
||||
import { filterInconsistentMetadataObjects } from '../Metadata/utils';
|
||||
import { filterInconsistentMetadataObjects } from '../Settings/utils';
|
||||
|
||||
/* Action constants */
|
||||
|
||||
@ -167,7 +167,7 @@ const makeRequest = (
|
||||
let finalReqBody;
|
||||
if (globals.consoleMode === SERVER_CONSOLE_MODE) {
|
||||
finalReqBody = upQuery;
|
||||
} else if (globals.consoleMode === 'cli') {
|
||||
} else if (globals.consoleMode === CLI_CONSOLE_MODE) {
|
||||
finalReqBody = migrationBody;
|
||||
}
|
||||
const url = migrateUrl;
|
||||
@ -179,7 +179,7 @@ const makeRequest = (
|
||||
};
|
||||
|
||||
const onSuccess = data => {
|
||||
if (globals.consoleMode === 'cli') {
|
||||
if (globals.consoleMode === CLI_CONSOLE_MODE) {
|
||||
dispatch(loadMigrationStatus()); // don't call for server mode
|
||||
}
|
||||
// dispatch(loadTriggers());
|
||||
|
@ -13,7 +13,7 @@ import {
|
||||
} from '../Add/addRemoteSchemaReducer';
|
||||
|
||||
import { VIEW_REMOTE_SCHEMA } from '../Actions';
|
||||
import ReloadRemoteSchema from '../../Metadata/MetadataOptions/ReloadRemoteSchema';
|
||||
import ReloadRemoteSchema from '../../Settings/MetadataOptions/ReloadRemoteSchema';
|
||||
|
||||
import { appPrefix } from '../constants';
|
||||
|
||||
|
@ -222,13 +222,13 @@ export const dropInconsistentObjects = () => {
|
||||
};
|
||||
|
||||
export const isMetadataStatusPage = () => {
|
||||
return window.location.pathname.includes('/metadata/status');
|
||||
return window.location.pathname.includes('/setting/metadata-status');
|
||||
};
|
||||
|
||||
export const redirectToMetadataStatus = () => {
|
||||
return dispatch => {
|
||||
return dispatch(
|
||||
push(globals.urlPrefix + '/metadata/status?is_redirected=true')
|
||||
push(globals.urlPrefix + '/settings/metadata-status?is_redirected=true')
|
||||
);
|
||||
};
|
||||
};
|
@ -0,0 +1 @@
|
||||
@import "../Settings.scss";
|
@ -3,7 +3,7 @@ import Sidebar from './Sidebar';
|
||||
import PageContainer from '../../Common/Layout/PageContainer/PageContainer';
|
||||
|
||||
const Container = ({ location, children, metadata }) => {
|
||||
const helmet = 'Metadata | Hasura';
|
||||
const helmet = 'Settings | Hasura';
|
||||
|
||||
const sidebar = <Sidebar location={location} metadata={metadata} />;
|
||||
|
@ -0,0 +1,56 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { clearAdminSecretState } from '../../../AppState';
|
||||
|
||||
import { showSuccessNotification } from '../../Common/Notification';
|
||||
import Button from '../../../Common/Button/Button';
|
||||
|
||||
class ClearAdminSecret extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.state = {
|
||||
isClearing: false,
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const metaDataStyles = require('../Settings.scss');
|
||||
|
||||
const { dispatch } = this.props;
|
||||
const { isClearing } = this.state;
|
||||
|
||||
return (
|
||||
<div className={metaDataStyles.display_inline}>
|
||||
<Button
|
||||
data-test="data-clear-access-key"
|
||||
className={metaDataStyles.margin_right}
|
||||
color="white"
|
||||
size="sm"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
|
||||
this.setState({ isClearing: true });
|
||||
|
||||
clearAdminSecretState();
|
||||
|
||||
dispatch(showSuccessNotification('Cleared admin-secret'));
|
||||
|
||||
this.setState({ isClearing: false });
|
||||
|
||||
this.props.router.push('/login');
|
||||
}}
|
||||
>
|
||||
{isClearing ? 'Clearing...' : 'Logout (clear admin-secret)'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ClearAdminSecret.propTypes = {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
dataHeaders: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default ClearAdminSecret;
|
46
console/src/components/Services/Settings/Logout/Logout.js
Normal file
46
console/src/components/Services/Settings/Logout/Logout.js
Normal file
@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import ClearAdminSecret from './ClearAdminSecret';
|
||||
|
||||
const Logout = props => {
|
||||
const styles = require('../Settings.scss');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.clear_fix} ${styles.padd_left} ${styles.padd_top} ${
|
||||
styles.metadata_wrapper
|
||||
} container-fluid`}
|
||||
>
|
||||
<div className={styles.subHeader}>
|
||||
<h2 className={`${styles.heading_text} ${styles.remove_pad_bottom}`}>
|
||||
Logout (clear admin-secret)
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div key="access_key_reset_1" className={styles.intro_note}>
|
||||
<div className={styles.content_width}>
|
||||
The console caches the admin-secret (HASURA_GRAPHQL_ADMIN_SECRET) in
|
||||
the browser. You can clear this cache to force a prompt for the
|
||||
admin-secret when the console is accessed next using this browser.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div key="access_key_reset_2">
|
||||
<ClearAdminSecret {...props} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = state => {
|
||||
return {
|
||||
...state.main,
|
||||
metadata: state.metadata,
|
||||
dataHeaders: { ...state.tables.dataHeaders },
|
||||
};
|
||||
};
|
||||
|
||||
const logoutConnector = connect => connect(mapStateToProps)(Logout);
|
||||
|
||||
export default logoutConnector;
|
@ -15,7 +15,7 @@ class ExportMetadata extends Component {
|
||||
this.state.isExporting = false;
|
||||
}
|
||||
render() {
|
||||
const metaDataStyles = require('../Metadata.scss');
|
||||
const metaDataStyles = require('../Settings.scss');
|
||||
return (
|
||||
<div className={metaDataStyles.display_inline}>
|
||||
<Button
|
@ -83,7 +83,7 @@ class ImportMetadata extends Component {
|
||||
});
|
||||
}
|
||||
render() {
|
||||
const metaDataStyles = require('../Metadata.scss');
|
||||
const metaDataStyles = require('../Settings.scss');
|
||||
return (
|
||||
<div className={metaDataStyles.display_inline}>
|
||||
<Button
|
@ -1,20 +1,17 @@
|
||||
import globals from '../../../../Globals';
|
||||
import React from 'react';
|
||||
import ExportMetadata from './ExportMetadata';
|
||||
import ImportMetadata from './ImportMetadata';
|
||||
import ReloadMetadata from './ReloadMetadata';
|
||||
import ResetMetadata from './ResetMetadata';
|
||||
import ClearAdminSecret from './ClearAdminSecret';
|
||||
import { CONSOLE_ADMIN_SECRET } from '../../../AppState';
|
||||
|
||||
const MetadataOptions = props => {
|
||||
const styles = require('../Metadata.scss');
|
||||
const styles = require('../Settings.scss');
|
||||
|
||||
const getMetadataImportExportSection = () => {
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.intro_note}>
|
||||
<h4>Import/Export</h4>
|
||||
<h4>Import/Export metadata</h4>
|
||||
<div className={styles.content_width}>
|
||||
Get Hasura metadata as JSON.
|
||||
</div>
|
||||
@ -47,7 +44,7 @@ const MetadataOptions = props => {
|
||||
</div>
|
||||
|
||||
<div key="meta_data_3" className={styles.intro_note}>
|
||||
<h4>Reset Metadata</h4>
|
||||
<h4>Reset metadata</h4>
|
||||
<div className={styles.content_width}>
|
||||
Permanently clear GraphQL Engine's metadata and configure it from
|
||||
scratch (tracking relevant tables and relationships). This process
|
||||
@ -62,36 +59,6 @@ const MetadataOptions = props => {
|
||||
);
|
||||
};
|
||||
|
||||
const getClearSecretSection = () => {
|
||||
let clearSecretSection = null;
|
||||
|
||||
if (window.localStorage[CONSOLE_ADMIN_SECRET]) {
|
||||
clearSecretSection = (
|
||||
<div>
|
||||
<div key="access_key_reset_1" className={styles.intro_note}>
|
||||
<h4>Clear {globals.adminSecretLabel} (logout)</h4>
|
||||
|
||||
<div className={styles.content_width}>
|
||||
The console caches the {globals.adminSecretLabel} (
|
||||
{globals.adminSecretLabel === 'access-key'
|
||||
? 'HASURA_GRAPHQL_ACCESS_KEY'
|
||||
: 'HASURA_GRAPHQL_ADMIN_SECRET'}
|
||||
) in the browser. You can clear this cache to force a prompt for
|
||||
the {globals.adminSecretLabel} when the console is accessed next
|
||||
using this browser.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div key="access_key_reset_2">
|
||||
<ClearAdminSecret {...props} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return clearSecretSection;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.clear_fix} ${styles.padd_left} ${styles.padd_top} ${
|
||||
@ -120,8 +87,6 @@ const MetadataOptions = props => {
|
||||
{getMetadataImportExportSection()}
|
||||
|
||||
{getMetadataUpdateSection()}
|
||||
|
||||
{getClearSecretSection()}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -21,7 +21,7 @@ class ReloadMetadata extends Component {
|
||||
const { dispatch } = this.props;
|
||||
const { isReloading } = this.state;
|
||||
|
||||
const metaDataStyles = require('../Metadata.scss');
|
||||
const metaDataStyles = require('../Settings.scss');
|
||||
|
||||
const reloadMetadataAndLoadInconsistentMetadata = e => {
|
||||
e.preventDefault();
|
@ -17,7 +17,7 @@ class ReloadRemoteSchema extends Component {
|
||||
render() {
|
||||
const { dispatch, remoteSchemaName } = this.props;
|
||||
const { isReloading } = this.state;
|
||||
const metaDataStyles = require('../Metadata.scss');
|
||||
const metaDataStyles = require('../Settings.scss');
|
||||
const reloadRemoteMetadataHandler = () => {
|
||||
this.setState({ isReloading: true });
|
||||
dispatch(
|
@ -6,7 +6,7 @@ import {
|
||||
showSuccessNotification,
|
||||
showErrorNotification,
|
||||
} from '../../Common/Notification';
|
||||
import metaDataStyles from '../Metadata.scss';
|
||||
import metaDataStyles from '../Settings.scss';
|
||||
import styles from '../../../Common/TableCommon/Table.scss';
|
||||
import CheckIcon from '../../../Common/Icons/Check';
|
||||
import CrossIcon from '../../../Common/Icons/Cross';
|
@ -1,16 +1,20 @@
|
||||
import React from 'react';
|
||||
import LeftContainer from '../../Common/Layout/LeftContainer/LeftContainer';
|
||||
import { Link } from 'react-router';
|
||||
import styles from '../../Common/TableCommon/Table.scss';
|
||||
import CheckIcon from '../../Common/Icons/Check';
|
||||
import CrossIcon from '../../Common/Icons/Cross';
|
||||
import globals from '../../../Globals';
|
||||
import { CLI_CONSOLE_MODE } from '../../../constants';
|
||||
import { getAdminSecret } from '../ApiExplorer/ApiRequest/utils';
|
||||
|
||||
import styles from '../../Common/TableCommon/Table.scss';
|
||||
|
||||
const Sidebar = ({ location, metadata }) => {
|
||||
const sectionsData = [];
|
||||
|
||||
sectionsData.push({
|
||||
key: 'actions',
|
||||
link: '/metadata/actions',
|
||||
link: '/settings/metadata-actions',
|
||||
dataTestVal: 'metadata-actions-link',
|
||||
title: 'Metadata Actions',
|
||||
});
|
||||
@ -20,7 +24,7 @@ const Sidebar = ({ location, metadata }) => {
|
||||
|
||||
sectionsData.push({
|
||||
key: 'status',
|
||||
link: '/metadata/status',
|
||||
link: '/settings/metadata-status',
|
||||
dataTestVal: 'metadata-status-link',
|
||||
title: (
|
||||
<div className={styles.display_flex}>
|
||||
@ -32,11 +36,23 @@ const Sidebar = ({ location, metadata }) => {
|
||||
|
||||
sectionsData.push({
|
||||
key: 'allowed-queries',
|
||||
link: '/metadata/allowed-queries',
|
||||
link: '/settings/allowed-queries',
|
||||
dataTestVal: 'allowed-queries-link',
|
||||
title: 'Allowed Queries',
|
||||
});
|
||||
|
||||
|
||||
const adminSecret = getAdminSecret();
|
||||
|
||||
if (adminSecret && globals.consoleMode !== CLI_CONSOLE_MODE) {
|
||||
sectionsData.push({
|
||||
key: 'logout',
|
||||
link: '/settings/logout',
|
||||
dataTestVal: 'logout-page-link',
|
||||
title: 'Logout (clear admin-secret)',
|
||||
});
|
||||
}
|
||||
|
||||
const currentLocation = location.pathname;
|
||||
|
||||
const sections = [];
|
@ -1 +1,4 @@
|
||||
export const SERVER_CONSOLE_MODE = 'server';
|
||||
export const CLI_CONSOLE_MODE = 'cli';
|
||||
|
||||
export const ADMIN_SECRET_HEADER_KEY = 'x-hasura-admin-secret';
|
||||
|
@ -1,38 +1,30 @@
|
||||
const envObj = `apiHost: '${process.env.API_HOST}',
|
||||
apiPort: '${process.env.API_PORT}',
|
||||
dataApiUrl: '${process.env.DATA_API_URL}',
|
||||
adminSecret: '${process.env.ADMIN_SECRET}',
|
||||
consoleMode: '${process.env.CONSOLE_MODE}',
|
||||
nodeEnv: '${process.env.NODE_ENV}',
|
||||
urlPrefix: '${process.env.URL_PREFIX}',
|
||||
enableTelemetry: ${process.env.ENABLE_TELEMETRY},
|
||||
assetsPath: '${process.env.ASSETS_PATH}',
|
||||
assetsVersion: '${process.env.ASSETS_VERSION}',
|
||||
serverVersion: '${process.env.SERVER_VERSION}',
|
||||
cdnAssets: ${process.env.CDN_ASSETS},
|
||||
`;
|
||||
|
||||
let appendObj;
|
||||
let envObj = `
|
||||
apiHost: '${process.env.API_HOST}',
|
||||
apiPort: '${process.env.API_PORT}',
|
||||
dataApiUrl: '${process.env.DATA_API_URL}',
|
||||
consoleMode: '${process.env.CONSOLE_MODE}',
|
||||
nodeEnv: '${process.env.NODE_ENV}',
|
||||
urlPrefix: '${process.env.URL_PREFIX}',
|
||||
enableTelemetry: ${process.env.ENABLE_TELEMETRY},
|
||||
assetsPath: '${process.env.ASSETS_PATH}',
|
||||
assetsVersion: '${process.env.ASSETS_VERSION}',
|
||||
serverVersion: '${process.env.SERVER_VERSION}',
|
||||
cdnAssets: ${process.env.CDN_ASSETS},`;
|
||||
|
||||
if (process.env.ADMIN_SECRET !== undefined) {
|
||||
appendObj = `
|
||||
adminSecret: '${process.env.ADMIN_SECRET}'`;
|
||||
envObj += `
|
||||
adminSecret: '${process.env.ADMIN_SECRET}',`;
|
||||
} else {
|
||||
// ADMIN_SECRET is undefined
|
||||
if (process.env.IS_ADMIN_SECRET_SET !== undefined) {
|
||||
appendObj = `isAdminSecretSet: ${process.env.IS_ADMIN_SECRET_SET}`;
|
||||
} else {
|
||||
// Both ADMIN_SECRET and IS_ADMIN_SECRET_SET is undefined
|
||||
if (process.env.ACCESS_KEY !== undefined) {
|
||||
appendObj = `accessKey: ${process.env.ACCESS_KEY}`;
|
||||
} else {
|
||||
appendObj = `isAccessKeySet: ${process.env.IS_ACCESS_KEY_SET}`;
|
||||
}
|
||||
envObj += `
|
||||
isAdminSecretSet: ${process.env.IS_ADMIN_SECRET_SET},`;
|
||||
}
|
||||
}
|
||||
|
||||
const env = `
|
||||
window.__env={\n\t\t${envObj}\t\t${appendObj}
|
||||
window.__env={
|
||||
${envObj}
|
||||
};
|
||||
`;
|
||||
|
||||
|
@ -7,7 +7,7 @@ import mainReducer from './components/Main/Actions';
|
||||
import apiExplorerReducer from 'components/Services/ApiExplorer/Actions';
|
||||
import progressBarReducer from 'components/App/Actions';
|
||||
import telemetryReducer from './telemetry/Actions';
|
||||
import { metadataReducer } from './components/Services/Metadata/Actions';
|
||||
import { metadataReducer } from './components/Services/Settings/Actions';
|
||||
|
||||
import { reducer as notifications } from 'react-notification-system-redux';
|
||||
|
||||
|
@ -19,7 +19,7 @@ import { eventRouterUtils } from './components/Services/EventTrigger';
|
||||
|
||||
import { getRemoteSchemaRouter } from './components/Services/RemoteSchema';
|
||||
|
||||
import generatedApiExplorer from './components/Services/ApiExplorer/ApiExplorerGenerator';
|
||||
import generatedApiExplorer from './components/Services/ApiExplorer/ApiExplorer';
|
||||
|
||||
import generatedVoyagerConnector from './components/Services/VoyagerView/VoyagerView';
|
||||
|
||||
@ -27,34 +27,35 @@ import about from './components/Services/About/About';
|
||||
|
||||
import generatedLoginConnector from './components/Login/Login';
|
||||
|
||||
import metadataContainer from './components/Services/Metadata/Container';
|
||||
import metadataOptionsContainer from './components/Services/Metadata/MetadataOptions/MetadataOptions';
|
||||
import metadataStatusContainer from './components/Services/Metadata/MetadataStatus/MetadataStatus';
|
||||
import allowedQueriesContainer from './components/Services/Metadata/AllowedQueries/AllowedQueries';
|
||||
import settingsContainer from './components/Services/Settings/Container';
|
||||
import metadataOptionsContainer from './components/Services/Settings/MetadataOptions/MetadataOptions';
|
||||
import metadataStatusContainer from './components/Services/Settings/MetadataStatus/MetadataStatus';
|
||||
import allowedQueriesContainer from './components/Services/Settings/AllowedQueries/AllowedQueries';
|
||||
import logoutContainer from './components/Services/Settings/Logout/Logout';
|
||||
|
||||
import { showErrorNotification } from './components/Services/Common/Notification';
|
||||
import { CLI_CONSOLE_MODE } from './constants';
|
||||
|
||||
const routes = store => {
|
||||
// load hasuractl migration status
|
||||
const requireMigrationStatus = (nextState, replaceState, cb) => {
|
||||
if (globals.consoleMode === 'cli') {
|
||||
store.dispatch(loadMigrationStatus()).then(
|
||||
const { dispatch } = store;
|
||||
|
||||
if (globals.consoleMode === CLI_CONSOLE_MODE) {
|
||||
dispatch(loadMigrationStatus()).then(
|
||||
() => {
|
||||
cb();
|
||||
},
|
||||
r => {
|
||||
if (r.code === 'data_api_error') {
|
||||
if (globals.adminSecret) {
|
||||
alert('Hasura CLI: ' + r.message);
|
||||
} else {
|
||||
alert(
|
||||
`Looks like CLI is not configured with the ${
|
||||
globals.adminSecretLabel
|
||||
}. Please configure and try again`
|
||||
);
|
||||
}
|
||||
dispatch(showErrorNotification('Error', null, r));
|
||||
} else {
|
||||
alert(
|
||||
'Hasura console is not able to reach your Hasura GraphQL engine instance. Please ensure that your ' +
|
||||
'instance is running and the endpoint is configured correctly.'
|
||||
dispatch(
|
||||
showErrorNotification(
|
||||
'Connection error',
|
||||
'Hasura console is not able to reach your Hasura GraphQL engine instance. Please ensure that your ' +
|
||||
'instance is running and the endpoint is configured correctly.'
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -102,17 +103,21 @@ const routes = store => {
|
||||
component={generatedVoyagerConnector(connect)}
|
||||
/>
|
||||
<Route path="about" component={about(connect)} />
|
||||
<Route path="metadata" component={metadataContainer(connect)}>
|
||||
<IndexRedirect to="actions" />
|
||||
<Route path="status" component={metadataStatusContainer(connect)} />
|
||||
<Route path="settings" component={settingsContainer(connect)}>
|
||||
<IndexRedirect to="metadata-actions" />
|
||||
<Route
|
||||
path="actions"
|
||||
path="metadata-actions"
|
||||
component={metadataOptionsContainer(connect)}
|
||||
/>
|
||||
<Route
|
||||
path="metadata-status"
|
||||
component={metadataStatusContainer(connect)}
|
||||
/>
|
||||
<Route
|
||||
path="allowed-queries"
|
||||
component={allowedQueriesContainer(connect)}
|
||||
/>
|
||||
<Route path="logout" component={logoutContainer(connect)} />
|
||||
</Route>
|
||||
{dataRouter}
|
||||
{eventRouter}
|
||||
|
@ -1,18 +1,14 @@
|
||||
import fetch from 'isomorphic-fetch';
|
||||
import { push } from 'react-router-redux';
|
||||
import globals from 'Globals';
|
||||
import { UPDATE_DATA_HEADERS } from 'components/Services/Data/DataActions';
|
||||
|
||||
import {
|
||||
LOAD_REQUEST,
|
||||
DONE_REQUEST,
|
||||
FAILED_REQUEST,
|
||||
ERROR_REQUEST,
|
||||
CONNECTION_FAILED,
|
||||
} from 'components/App/Actions';
|
||||
|
||||
import { LOGIN_IN_PROGRESS, LOGIN_ERROR } from 'components/Main/Actions';
|
||||
|
||||
const requestAction = (
|
||||
url,
|
||||
options,
|
||||
@ -25,7 +21,7 @@ const requestAction = (
|
||||
}
|
||||
|
||||
return dispatch => {
|
||||
const p1 = new Promise((resolve, reject) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
dispatch({ type: LOAD_REQUEST });
|
||||
fetch(url, options).then(
|
||||
response => {
|
||||
@ -59,18 +55,9 @@ const requestAction = (
|
||||
});
|
||||
}
|
||||
if (msg.code && msg.code === 'access-denied') {
|
||||
dispatch({
|
||||
type: UPDATE_DATA_HEADERS,
|
||||
data: {
|
||||
'content-type': 'application/json',
|
||||
[`x-hasura-${
|
||||
globals.adminSecretLabel
|
||||
}`]: globals.adminSecret,
|
||||
},
|
||||
});
|
||||
dispatch({ type: LOGIN_IN_PROGRESS, data: false });
|
||||
dispatch({ type: LOGIN_ERROR, data: false });
|
||||
dispatch(push(globals.urlPrefix + '/login'));
|
||||
if (window.location.pathname !== globals.urlPrefix + '/login') {
|
||||
dispatch(push(globals.urlPrefix + '/login'));
|
||||
}
|
||||
}
|
||||
reject(msg);
|
||||
});
|
||||
@ -84,13 +71,11 @@ const requestAction = (
|
||||
});
|
||||
},
|
||||
error => {
|
||||
console.error(error);
|
||||
console.error('Request error: ', error);
|
||||
dispatch({ type: FAILED_REQUEST });
|
||||
dispatch({ type: CONNECTION_FAILED });
|
||||
if (ERROR) {
|
||||
dispatch({
|
||||
type: ERROR,
|
||||
code: 'server-connection-failed',
|
||||
message: error.message,
|
||||
data: error.message,
|
||||
});
|
||||
@ -99,7 +84,6 @@ const requestAction = (
|
||||
}
|
||||
);
|
||||
});
|
||||
return p1;
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -1,96 +1,43 @@
|
||||
import {
|
||||
loadAdminSecretState,
|
||||
clearAdminSecretState,
|
||||
CONSOLE_ADMIN_SECRET,
|
||||
} from '../components/AppState';
|
||||
import { clearAdminSecretState } from '../components/AppState';
|
||||
import globals from '../Globals';
|
||||
import Endpoints, { globalCookiePolicy } from '../Endpoints';
|
||||
|
||||
import requestAction from './requestActionPlain';
|
||||
import { verifyLogin } from '../components/Login/Actions';
|
||||
|
||||
import { UPDATE_DATA_HEADERS } from '../components/Services/Data/DataActions';
|
||||
import { changeRequestHeader } from '../components/Services/ApiExplorer/Actions';
|
||||
|
||||
import { SERVER_CONSOLE_MODE } from '../constants';
|
||||
|
||||
const checkValidity = adminSecret => {
|
||||
return dispatch => {
|
||||
const url = Endpoints.getSchema;
|
||||
const currentSchema = 'public';
|
||||
const headers = {
|
||||
'content-type': 'application/json',
|
||||
[`x-hasura-${globals.adminSecretLabel}`]: adminSecret,
|
||||
};
|
||||
const options = {
|
||||
credentials: globalCookiePolicy,
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: JSON.stringify({
|
||||
type: 'select',
|
||||
args: {
|
||||
table: {
|
||||
name: 'hdb_table',
|
||||
schema: 'hdb_catalog',
|
||||
},
|
||||
columns: ['table_schema'],
|
||||
where: { table_schema: currentSchema },
|
||||
limit: 1,
|
||||
},
|
||||
}),
|
||||
};
|
||||
return dispatch(requestAction(url, options));
|
||||
};
|
||||
};
|
||||
import { getAdminSecret } from '../components/Services/ApiExplorer/ApiRequest/utils';
|
||||
import { CLI_CONSOLE_MODE } from '../constants';
|
||||
|
||||
const validateLogin = ({ dispatch }) => {
|
||||
return (nextState, replaceState, cb) => {
|
||||
// Validate isAdminSecretSet env is set by server or adminSecret env is set by cli
|
||||
// care about admin secret only if it is set
|
||||
if (globals.isAdminSecretSet || globals.adminSecret) {
|
||||
let adminSecret = '';
|
||||
// Check the console mode and retrieve adminSecret accordingly.
|
||||
if (globals.consoleMode === SERVER_CONSOLE_MODE) {
|
||||
adminSecret = loadAdminSecretState(CONSOLE_ADMIN_SECRET);
|
||||
} else {
|
||||
adminSecret = globals.adminSecret;
|
||||
}
|
||||
dispatch(checkValidity(adminSecret))
|
||||
.then(() => {
|
||||
return Promise.all([
|
||||
dispatch({
|
||||
type: UPDATE_DATA_HEADERS,
|
||||
data: {
|
||||
'content-type': 'application/json',
|
||||
[`x-hasura-${globals.adminSecretLabel}`]: adminSecret,
|
||||
},
|
||||
}),
|
||||
dispatch(
|
||||
changeRequestHeader(
|
||||
1,
|
||||
'key',
|
||||
`x-hasura-${globals.adminSecretLabel}`,
|
||||
true
|
||||
)
|
||||
),
|
||||
dispatch(changeRequestHeader(1, 'value', adminSecret, true)),
|
||||
]);
|
||||
})
|
||||
.then(() => {
|
||||
if (nextState.location.pathname === '/login') {
|
||||
replaceState('/');
|
||||
}
|
||||
cb();
|
||||
})
|
||||
.catch(() => {
|
||||
// Clear state from the localStorage if there exists one
|
||||
const validationSuccessCallback = () => {
|
||||
if (nextState.location.pathname === '/login') {
|
||||
replaceState('/');
|
||||
}
|
||||
cb();
|
||||
};
|
||||
|
||||
const validationFailureCallback = () => {
|
||||
if (globals.consoleMode !== CLI_CONSOLE_MODE) {
|
||||
clearAdminSecretState();
|
||||
if (nextState.location.pathname !== '/login') {
|
||||
replaceState('/login');
|
||||
}
|
||||
cb();
|
||||
});
|
||||
}
|
||||
|
||||
if (nextState.location.pathname !== '/login') {
|
||||
replaceState('/login');
|
||||
}
|
||||
cb();
|
||||
};
|
||||
|
||||
const adminSecret = getAdminSecret();
|
||||
|
||||
verifyLogin({
|
||||
adminSecret,
|
||||
successCallback: validationSuccessCallback,
|
||||
errorCallback: validationFailureCallback,
|
||||
dispatch,
|
||||
});
|
||||
} else {
|
||||
cb();
|
||||
return;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user