diff --git a/.env.example b/.env.example index 56d7dc5..dd9c57f 100644 --- a/.env.example +++ b/.env.example @@ -72,5 +72,5 @@ DEKART_STATIC_FILES= REACT_APP_CUSTOM_CODE= DEKART_DEV_CLAIMS_EMAIL= DEKART_DEV_REFRESH_TOKEN= -DEKART_DOCKER_DEV_TAG= dekart-dev -DEKART_DOCKER_E2E_TAG= dekart-e2e +DEKART_DOCKER_DEV_TAG=dekart-dev +DEKART_DOCKER_E2E_TAG=dekart-e2e diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 0533e56..609defa 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -234,6 +234,8 @@ jobs: -e DEKART_SNOWFLAKE_ACCOUNT_ID=${{ secrets.SNOWFLAKE_ACCOUNT_ID }} \ -e DEKART_SNOWFLAKE_USER=${{ secrets.SNOWFLAKE_USER }} \ -e DEKART_SNOWFLAKE_PASSWORD=${{ secrets.SNOWFLAKE_PASSWORD }} \ + -e DEKART_DEV_CLAIMS_EMAIL=test@gmail.com \ + -e DEKART_REQUIRE_AMAZON_OIDC=1 \ dekartxyz/dekart:${{ env.IMAGE_CACHE_KEY }} - name: Upload cypress artifacts if: failure() diff --git a/.gitignore b/.gitignore index 583d0a2..e781d80 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,7 @@ cypress/screenshots # IDE .idea -testtmp* \ No newline at end of file +testtmp* + +# .env +.env.* \ No newline at end of file diff --git a/Makefile b/Makefile index 9f7d903..f5622e3 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,10 @@ -.PHONY: proto-build proto-docker proto docker docker-compose-up docker-compose-rm version minor patch +.PHONY: proto-clean proto-build proto-docker proto nodetest docker-compose-up down cloudsql up-and-down # load .env # https://lithic.tech/blog/2020-05/makefile-dot-env ifneq (,$(wildcard ./.env)) include .env - export + # export # don't export, it will be available only in this Makefile endif UNAME := $(shell uname -m) @@ -184,8 +184,6 @@ docker: # build docker for local use up-and-down: docker-compose --env-file .env --profile local up; docker-compose --env-file .env --profile local down --volumes - - up: docker-compose --env-file .env --profile local up @@ -195,12 +193,24 @@ down: cloudsql: docker-compose --env-file .env --profile cloudsql up -server: + +define run_server + @set -a; \ + . $(1); \ + set +a; \ go run ./src/server/main.go +endef + +# Pattern rule to match any target starting with ".env." +server-%: + $(call run_server,.env.$*) + +# Rule for the default .env file +server: + $(call run_server,.env) npm: npm i --legacy-peer-deps - prerelease: npm version prerelease --preid=rc preminor: diff --git a/cypress/e2e/athena/spec.cy.js b/cypress/e2e/athena/spec.cy.js index 5cf0664..a06554d 100644 --- a/cypress/e2e/athena/spec.cy.js +++ b/cypress/e2e/athena/spec.cy.js @@ -4,7 +4,7 @@ import copy from '../../fixtures/copy.json' describe('basic query flow', () => { it('should make simple athena query and get ready status', () => { cy.visit('/') - cy.get(`button:contains("${copy.create_report}")`).click() + cy.get('button#dekart-create-report').click() cy.get('button:contains("Add data from...")').click() cy.get('span:contains("SQL query")').click() cy.get('textarea').type(copy.simple_athena_query, { force: true }) @@ -17,7 +17,7 @@ describe('basic query flow', () => { describe('cancelling query', () => { it('should cancels query', () => { cy.visit('/') - cy.get(`button:contains("${copy.create_report}")`).click() + cy.get('button#dekart-create-report').click() cy.get('button:contains("Add data from...")').click() cy.get('span:contains("SQL query")').click() cy.get('textarea').type(copy.simple_athena_query, { force: true }) diff --git a/cypress/e2e/bq/basicFlow.cy.js b/cypress/e2e/bq/basicFlow.cy.js index eb57d11..e837bc3 100644 --- a/cypress/e2e/bq/basicFlow.cy.js +++ b/cypress/e2e/bq/basicFlow.cy.js @@ -4,7 +4,7 @@ import copy from '../../fixtures/copy.json' describe('basic query flow', () => { it('should make simple bigquery query and get ready status', () => { cy.visit('/') - cy.get(`button:contains("${copy.create_report}")`).click() + cy.get('button#dekart-create-report').click() cy.get('button:contains("Add data from...")').click() cy.get('span:contains("SQL query")').click() cy.get('textarea').type(copy.simple_sql_query, { force: true }) diff --git a/cypress/e2e/bq/cancelQuery.cy.js b/cypress/e2e/bq/cancelQuery.cy.js index dac7dde..054e6b6 100644 --- a/cypress/e2e/bq/cancelQuery.cy.js +++ b/cypress/e2e/bq/cancelQuery.cy.js @@ -4,7 +4,7 @@ import copy from '../../fixtures/copy.json' describe('cancelling query', () => { it('should cancels query', () => { cy.visit('/') - cy.get(`button:contains("${copy.create_report}")`).click() + cy.get('button#dekart-create-report').click() cy.get('button:contains("Add data from...")').click() cy.get('span:contains("SQL query")').click() cy.get('textarea').type(copy.simple_sql_query, { force: true }) diff --git a/cypress/e2e/bq/scripts.cy.js b/cypress/e2e/bq/scripts.cy.js index d6dfbbc..c6c72b1 100644 --- a/cypress/e2e/bq/scripts.cy.js +++ b/cypress/e2e/bq/scripts.cy.js @@ -4,7 +4,7 @@ import copy from '../../fixtures/copy.json' describe('support bq scripts', () => { it('retrieve result for bigquery script', () => { cy.visit('/') - cy.get(`button:contains("${copy.create_report}")`).click() + cy.get('button#dekart-create-report').click() cy.get('button:contains("Add data from...")').click() cy.get('span:contains("SQL query")').click() cy.get('textarea').type(copy.bigquery_script, { force: true }) diff --git a/cypress/e2e/bq/updateDataset.cy.js b/cypress/e2e/bq/updateDataset.cy.js index 5090ee6..96046cb 100644 --- a/cypress/e2e/bq/updateDataset.cy.js +++ b/cypress/e2e/bq/updateDataset.cy.js @@ -5,7 +5,7 @@ describe('update dataset', () => { it('should persist kepler config when updating dataset', () => { // create report cy.visit('/') - cy.get(`button:contains("${copy.create_report}")`).click() + cy.get('button#dekart-create-report').click() // first query cy.get('button:contains("Add data from...")').click() diff --git a/cypress/e2e/google-oauth/testConnection.cy.js b/cypress/e2e/google-oauth/testConnection.cy.js index 725665e..7b45414 100644 --- a/cypress/e2e/google-oauth/testConnection.cy.js +++ b/cypress/e2e/google-oauth/testConnection.cy.js @@ -6,7 +6,7 @@ describe('basic query flow', () => { cy.visit('/') // create connection - cy.get('button:contains("Create connection")').click() + cy.get('button:contains("BigQuery")').click() const randomConnectionName = `test-${Math.floor(Math.random() * 1000000)}` cy.get('div.ant-modal-title').should('contain', 'BigQuery') cy.get('input#connectionName').clear() diff --git a/cypress/e2e/pg/cancelQuery.cy.js b/cypress/e2e/pg/cancelQuery.cy.js index d5a1d03..ae7996f 100644 --- a/cypress/e2e/pg/cancelQuery.cy.js +++ b/cypress/e2e/pg/cancelQuery.cy.js @@ -4,7 +4,7 @@ import copy from '../../fixtures/copy.json' describe('cancelling query', () => { it('should cancels query', () => { cy.visit('/') - cy.get(`button:contains("${copy.create_report}")`).click() + cy.get('button#dekart-create-report').click() cy.get('button:contains("Add data from...")').click() cy.get('span:contains("SQL query")').click() cy.get('textarea').type(copy.simple_pg_query_long, { force: true }) diff --git a/cypress/e2e/pg/happyPath.cy.js b/cypress/e2e/pg/happyPath.cy.js index 8ab3e2d..e370834 100644 --- a/cypress/e2e/pg/happyPath.cy.js +++ b/cypress/e2e/pg/happyPath.cy.js @@ -4,7 +4,7 @@ import copy from '../../fixtures/copy.json' describe('happy path', () => { it('should make simple postgres query and get ready status', () => { cy.visit('/') - cy.get(`button:contains("${copy.create_report}")`).click() + cy.get('button#dekart-create-report').click() cy.get('button:contains("Add data from...")').click() cy.get('span:contains("SQL query")').click() cy.get('textarea').type(copy.simple_pg_query, { force: true }) diff --git a/cypress/e2e/snowflake-s3/cancelQuery.cy.js b/cypress/e2e/snowflake-s3/cancelQuery.cy.js index 9ccc385..410eaf5 100644 --- a/cypress/e2e/snowflake-s3/cancelQuery.cy.js +++ b/cypress/e2e/snowflake-s3/cancelQuery.cy.js @@ -4,7 +4,7 @@ import copy from '../../fixtures/copy.json' describe('cancelling query', () => { it('should cancels query', () => { cy.visit('/') - cy.get(`button:contains("${copy.create_report}")`).click() + cy.get('button#dekart-create-report').click() cy.get('button:contains("Add data from...")').click() cy.get('span:contains("SQL query")').click() cy.get('textarea').type(copy.simple_snowflake_query, { force: true }) diff --git a/cypress/e2e/snowflake-s3/emptyResult.cy.js b/cypress/e2e/snowflake-s3/emptyResult.cy.js index fc49748..08361d4 100644 --- a/cypress/e2e/snowflake-s3/emptyResult.cy.js +++ b/cypress/e2e/snowflake-s3/emptyResult.cy.js @@ -4,7 +4,7 @@ import copy from '../../fixtures/copy.json' describe('empty path', () => { it('should complete query returning empty result', () => { cy.visit('/') - cy.get(`button:contains("${copy.create_report}")`).click() + cy.get('button#dekart-create-report').click() cy.get('button:contains("Add data from...")').click() cy.get('span:contains("SQL query")').click() cy.get('textarea').type('SELECT ROUND(uniform(-90::float, 90::float, random()), 6) AS lat, ROUND(uniform(-180::float, 180::float, random()), 6) AS lon FROM TABLE(GENERATOR(ROWCOUNT => 0))', { force: true }) diff --git a/cypress/e2e/snowflake-s3/fork.cy.js b/cypress/e2e/snowflake-s3/fork.cy.js index 58270b4..a3d1038 100644 --- a/cypress/e2e/snowflake-s3/fork.cy.js +++ b/cypress/e2e/snowflake-s3/fork.cy.js @@ -21,9 +21,8 @@ async function getColorAtMapCenter (win) { describe('fork', () => { it('should have same viz style after fork', () => { - let originalColor cy.visit('/') - cy.get(`button:contains("${copy.create_report}")`).click() + cy.get('button#dekart-create-report').click() cy.get('button:contains("Add data from...")').click() cy.get('span:contains("SQL query")').click() cy.get('textarea').type('select 0 as lat, 0 as lon', { force: true }) diff --git a/cypress/e2e/snowflake-s3/happyPath.cy.js b/cypress/e2e/snowflake-s3/happyPath.cy.js index 3473900..14d8b57 100644 --- a/cypress/e2e/snowflake-s3/happyPath.cy.js +++ b/cypress/e2e/snowflake-s3/happyPath.cy.js @@ -4,7 +4,7 @@ import copy from '../../fixtures/copy.json' describe('happy path', () => { it('should make simple snowflake query and get ready status', () => { cy.visit('/') - cy.get(`button:contains("${copy.create_report}")`).click() + cy.get('button#dekart-create-report').click() cy.get('button:contains("Add data from...")').click() cy.get('span:contains("SQL query")').click() cy.get('textarea').type(copy.simple_snowflake_query, { force: true }) diff --git a/cypress/e2e/snowflake-s3/spec.cy.js b/cypress/e2e/snowflake-s3/spec.cy.js index 0490a98..e914212 100644 --- a/cypress/e2e/snowflake-s3/spec.cy.js +++ b/cypress/e2e/snowflake-s3/spec.cy.js @@ -4,7 +4,7 @@ import copy from '../../fixtures/copy.json' describe('basic query flow', () => { it('should make simple snowflake query and get ready status', () => { cy.visit('/') - cy.get(`button:contains("${copy.create_report}")`).click() + cy.get('button#dekart-create-report').click() cy.get('button:contains("Add data from...")').click() cy.get('span:contains("SQL query")').click() cy.get('textarea').type(copy.simple_snowflake_query, { force: true }) @@ -18,7 +18,7 @@ describe('basic query flow', () => { describe('cancelling query', () => { it('should cancels query', () => { cy.visit('/') - cy.get(`button:contains("${copy.create_report}")`).click() + cy.get('button#dekart-create-report').click() cy.get('button:contains("Add data from...")').click() cy.get('span:contains("SQL query")').click() cy.get('textarea').type(copy.simple_snowflake_query, { force: true }) diff --git a/cypress/e2e/snowflake/happyPath.cy.js b/cypress/e2e/snowflake/happyPath.cy.js index ec91be8..764b9f0 100644 --- a/cypress/e2e/snowflake/happyPath.cy.js +++ b/cypress/e2e/snowflake/happyPath.cy.js @@ -4,7 +4,7 @@ import copy from '../../fixtures/copy.json' describe('happy path', () => { it('should make simple snowflake query and get ready status', () => { cy.visit('/') - cy.get(`button:contains("${copy.create_report}")`).click() + cy.get('button#dekart-create-report').click() cy.get('textarea').type(copy.simple_snowflake_query, { force: true }) cy.get(`button:contains("${copy.execute}")`).click() cy.get(`span:contains("${copy.ready}")`, { timeout: 20000 }).should('be.visible') diff --git a/cypress/e2e/snowflake/runAllQueries.cy.js b/cypress/e2e/snowflake/runAllQueries.cy.js index 01cc2c4..e667988 100644 --- a/cypress/e2e/snowflake/runAllQueries.cy.js +++ b/cypress/e2e/snowflake/runAllQueries.cy.js @@ -4,7 +4,7 @@ import copy from '../../fixtures/copy.json' describe('run all queries', () => { it('should run all queries', () => { cy.visit('/') - cy.get(`button:contains("${copy.create_report}")`).click() + cy.get('button#dekart-create-report').click() cy.get('textarea').type('SELECT ROUND(uniform(-90::float, 90::float, random()), 6) AS lat, ROUND(uniform(-180::float, 180::float, random()), 6) AS lon FROM TABLE(GENERATOR(ROWCOUNT => 1000))', { force: true }) cy.get(`button:contains("${copy.execute}")`).click() cy.get(`span:contains("${copy.ready}")`, { timeout: 20000 }).should('be.visible') diff --git a/cypress/fixtures/copy.json b/cypress/fixtures/copy.json index c5eda93..9ea72d9 100644 --- a/cypress/fixtures/copy.json +++ b/cypress/fixtures/copy.json @@ -1,5 +1,4 @@ { - "create_report": "Report", "bigquery_query": "BigQuery query", "athena_query": "Athena query", "snowflake_query": "Snowflake query", diff --git a/go.mod b/go.mod index a3aa9e4..429cbac 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( ) require ( + cloud.google.com/go/secretmanager v1.10.0 github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/snowflakedb/gosnowflake v1.6.22 github.com/stretchr/testify v1.8.1 diff --git a/go.sum b/go.sum index 92f703f..e35a2ee 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ cloud.google.com/go/datacatalog v1.13.0 h1:4H5IJiyUE0X6ShQBqgFFZvGGcrwGVndTwUSLP cloud.google.com/go/iam v1.1.0 h1:67gSqaPukx7O8WLLHMa0PNs3EBGd2eE4d+psbO/CO94= cloud.google.com/go/iam v1.1.0/go.mod h1:nxdHjaKfCr7fNYx/HJMM8LgiMugmveWlkatear5gVyk= cloud.google.com/go/longrunning v0.4.2 h1:WDKiiNXFTaQ6qz/G8FCOkuY9kJmOJGY67wPUC1M2RbE= +cloud.google.com/go/secretmanager v1.10.0 h1:pu03bha7ukxF8otyPKTFdDz+rr9sE3YauS5PliDXK60= +cloud.google.com/go/secretmanager v1.10.0/go.mod h1:MfnrdvKMPNra9aZtQFvBcvRU54hbPD8/HayQdlUgJpU= cloud.google.com/go/storage v1.30.1 h1:uOdMxAs8HExqBlnLtnQyP0YkvbiDpdGShGKtx6U/oNM= cloud.google.com/go/storage v1.30.1/go.mod h1:NfxhC0UJE1aXSx7CIIbCf7y9HKT7BiccwkR7+P7gN8E= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= diff --git a/migrations/000023__cloud_snowflake_connection.up.sql b/migrations/000023__cloud_snowflake_connection.up.sql new file mode 100644 index 0000000..496d2cf --- /dev/null +++ b/migrations/000023__cloud_snowflake_connection.up.sql @@ -0,0 +1,6 @@ +ALTER TABLE connections +ADD COLUMN connection_type INT default 1, +ADD COLUMN snowflake_account_id text default NULL, +ADD COLUMN snowflake_username text default NULL, +ADD COLUMN snowflake_password_encrypted text default NULL, +ADD COLUMN snowflake_warehouse text default NULL; diff --git a/proto/dekart.proto b/proto/dekart.proto index 41999a8..a9e7064 100644 --- a/proto/dekart.proto +++ b/proto/dekart.proto @@ -80,8 +80,6 @@ message GetUserStreamResponse { StreamOptions stream_options = 1; int64 connection_update = 2; string email = 3; // user email used to show user icon in UI - bool sensitive_scopes_granted = 4; // user has granted sensitive scopes - bool sensitive_scopes_granted_once = 5; // user has granted sensitive scopes at least once, now we request all scopes at once } message TestConnectionRequest { @@ -127,6 +125,22 @@ message Connection { int64 updated_at = 8; int64 dataset_count = 9; bool can_store_files = 10; + enum ConnectionType { + CONNECTION_TYPE_UNSPECIFIED = 0; + CONNECTION_TYPE_BIGQUERY = 1; + CONNECTION_TYPE_SNOWFLAKE = 2; + } + ConnectionType connection_type = 11; + string snowflake_account_id = 12; + string snowflake_username = 13; + Secret snowflake_password = 14; + string snowflake_warehouse = 15; +} + +message Secret { + string client_encrypted = 1; // encrypted with client key + string server_encrypted = 2; // encrypted with server key + int32 length = 3; // length of the password for placeholder } message GetUsageRequest {} @@ -180,6 +194,10 @@ message GetEnvResponse { TYPE_UX_ACCESS_ERROR_INFO_HTML = 13; TYPE_UX_NOT_FOUND_ERROR_INFO_HTML = 14; TYPE_UX_SAMPLE_QUERY_SQL = 15; + TYPE_AES_KEY = 16; + TYPE_AES_IV = 17; + TYPE_AUTH_ENABLED = 18; + TYPE_USER_DEFINED_CONNECTION = 19; } Type type = 1; string value = 2; @@ -191,6 +209,7 @@ message GetEnvResponse { message RedirectState { string token_json = 1; string error = 2; + bool sensitive_scopes_granted = 3; // if true, sensitive scope are granted } // AuthState is used to pass state between UI, auth endpoint and Google OAuth via redirect @@ -238,6 +257,8 @@ message Report { bool is_author = 9; // user is the author of the report int64 created_at = 10; int64 updated_at = 11; + bool is_sharable = 12; // can be shared with other users (depends where result is stored) + bool need_sensitive_scope = 13; // need sensitive scope to run queries and read results } message Dataset { @@ -261,7 +282,7 @@ message Query { JOB_STATUS_PENDING = 1; JOB_STATUS_RUNNING = 2; JOB_STATUS_DONE_LEGACY = 3; // legacy for backwards compatibility - JOB_STATUS_READING_RESULTS = 4; // job is done proccessing results + JOB_STATUS_READING_RESULTS = 4; // job is done processing results JOB_STATUS_DONE = 5; } diff --git a/src/client/App.js b/src/client/App.js index 3e2e6d1..fe55c57 100644 --- a/src/client/App.js +++ b/src/client/App.js @@ -19,6 +19,7 @@ import { authRedirect, setRedirectState } from './actions/redirect' import { subscribeUserStream, unsubscribeUserStream } from './actions/user' import GrantScopesPage from './GrantScopesPage' import { loadLocalStorage } from './actions/localStorage' +import { loadSessionStorage } from './actions/sessionStorage' // RedirectState reads states passed in the URL from the server function RedirectState () { @@ -50,11 +51,10 @@ function AppRedirect () { const httpError = useSelector(state => state.httpError) const { status, doNotAuthenticate } = httpError const { newReportId } = useSelector(state => state.reportStatus) - const userStream = useSelector(state => state.user.stream) - const needSensitiveScopes = useSelector(state => state.env.needSensitiveScopes) - const sensitiveScopesGranted = userStream?.sensitiveScopesGranted + const sensitiveScopesNeeded = useSelector(state => state.user.sensitiveScopesNeeded) + const sensitiveScopesGranted = useSelector(state => state.user.sensitiveScopesGranted) const sensitiveScopesGrantedOnce = useSelector(state => state.user.sensitiveScopesGrantedOnce) - const location = useLocation() + const redirectStateReceived = useSelector(state => state.user.redirectStateReceived) const dispatch = useDispatch() useEffect(() => { @@ -62,17 +62,17 @@ function AppRedirect () { const state = new AuthState() state.setUiUrl(window.location.href) state.setAction(AuthState.Action.ACTION_REQUEST_CODE) - state.setSensitiveScope(sensitiveScopesGrantedOnce) // if user has granted sensitive scopes once, request them right away without onboarding + state.setSensitiveScope(sensitiveScopesGranted || sensitiveScopesGrantedOnce) // if user has granted sensitive scopes on this device request token with sensitive scopes dispatch(authRedirect(state)) } - }, [status, doNotAuthenticate, dispatch, sensitiveScopesGrantedOnce]) + }, [status, doNotAuthenticate, dispatch, sensitiveScopesGrantedOnce, sensitiveScopesGranted]) if (status === 401 && doNotAuthenticate === false) { // redirect to authentication endpoint from useEffect above return null } - if (httpError.status && location.pathname !== `/${httpError.status}`) { + if (httpError.status) { return } @@ -80,7 +80,9 @@ function AppRedirect () { return } - if (userStream && needSensitiveScopes && !sensitiveScopesGranted) { + if ( + redirectStateReceived && sensitiveScopesNeeded && !sensitiveScopesGranted + ) { return } @@ -100,40 +102,60 @@ function PageHistory ({ visitedPages }) { return null } +function NotFoundPage () { + return ( + } title='404' subTitle={ + <> +

Page not found

+ + } + /> + ) +} + export default function App () { const errorMessage = useSelector(state => state.httpError.message) const status = useSelector(state => state.httpError.status) const env = useSelector(state => state.env) - const usage = useSelector(state => state.usage) + const envLoaded = env.loaded const userDefinedConnection = useSelector(state => state.connection.userDefined) const dispatch = useDispatch() const visitedPages = React.useRef(['/']) + const storageLoaded = useSelector(state => state.storage.loaded) + const page401 = window.location.pathname.startsWith('/401') useEffect(() => { + dispatch(loadSessionStorage()) dispatch(loadLocalStorage()) }, [dispatch]) useEffect(() => { - if (window.location.pathname.startsWith('/401')) { - // do not load env and usage on 401 page + if (page401 || envLoaded) { return } - if (status === 401) { + dispatch(getEnv()) + }, [dispatch, page401, envLoaded]) + + // do not call API until storage is loaded and environment is loaded and not 401 + const loadData = storageLoaded && env.loaded && status !== 401 + + useEffect(() => { + if (!loadData) { return } - if (!env.loaded) { - dispatch(getEnv()) - } - if (!usage.loaded) { - dispatch(getUsage()) - } - }, [env, usage, dispatch, status]) - useEffect(() => { dispatch(subscribeUserStream()) + dispatch(getUsage()) return () => { dispatch(unsubscribeUserStream()) } - }, [dispatch]) + }, [dispatch, loadData]) + + // do not render until storage is loaded and environment is loaded + const startRender = loadData || page401 || status === 401 + if (!startRender) { + return null + } return ( @@ -170,7 +192,7 @@ export default function App () { } title='401' subTitle={errorMessage || 'Unauthorized'} /> - } title='404' subTitle='Page not found' /> + diff --git a/src/client/ConnectionModal.js b/src/client/ConnectionModal.js index 90f00b0..8d549d4 100644 --- a/src/client/ConnectionModal.js +++ b/src/client/ConnectionModal.js @@ -5,26 +5,26 @@ import { useSelector, useDispatch } from 'react-redux' import Button from 'antd/es/button' import styles from './ConnectionModal.module.css' import { useEffect } from 'react' -import { archiveConnection, closeConnectionDialog, connectionChanged, saveConnection, testConnection } from './actions/connection' +import { archiveConnection, closeConnectionDialog, connectionChanged, reOpenDialog, saveConnection, testConnection } from './actions/connection' import { CheckCircleTwoTone, ExclamationCircleTwoTone, LoadingOutlined } from '@ant-design/icons' import Tooltip from 'antd/es/tooltip' import AutoComplete from 'antd/es/auto-complete' import Alert from 'antd/es/alert' +import { Connection } from '../proto/dekart_pb' +import { DatasourceIcon } from './Datasource' -function Footer ({ form }) { +function Footer ({ form, testDisabled }) { const { dialog, test } = useSelector(state => state.connection) const { tested, testing, error: testError, success: testSuccess } = test - const { id, loading } = dialog - - const connection = useSelector(state => state.connection.list.find(s => s.id === id)) + const { id, loading, connectionType } = dialog const dispatch = useDispatch() return (
@@ -51,39 +50,87 @@ function Footer ({ form }) { ) } -export default function ConnectionModal () { - const { dialog, projects } = useSelector(state => state.connection) - const { visible, id, loading } = dialog - - const env = useSelector(state => state.env) - const { BIGQUERY_PROJECT_ID, CLOUD_STORAGE_BUCKET } = env.variables - +function SnowflakeConnectionModal ({ form }) { + const { dialog } = useSelector(state => state.connection) + const { id, loading } = dialog + const dispatch = useDispatch() const connection = useSelector(state => state.connection.list.find(s => s.id === id)) + const datasetUsed = connection?.datasetCount > 0 + const passwordChanged = form.getFieldValue('snowflakePassword') !== connection?.snowflakePassword + return ( + Snowflake} + onCancel={() => dispatch(closeConnectionDialog())} + footer={