diff --git a/e2e/questdb b/e2e/questdb index 8a85eb338..ea866846e 160000 --- a/e2e/questdb +++ b/e2e/questdb @@ -1 +1 @@ -Subproject commit 8a85eb338b8ae9929a16fac7df2a0b8c7e5e6eae +Subproject commit ea866846e9026608545d77c82372f84df4c93aed diff --git a/e2e/tests/console/download.spec.js b/e2e/tests/console/download.spec.js index 06891426a..d71437db7 100644 --- a/e2e/tests/console/download.spec.js +++ b/e2e/tests/console/download.spec.js @@ -1,14 +1,21 @@ describe("download functionality", () => { beforeEach(() => { cy.loadConsoleWithAuth() - cy.getEditorContent().should("be.visible") + cy.getEditor().should("be.visible") + + cy.document().should((doc) => { + const activeElement = doc.activeElement + expect(activeElement).to.exist + expect(activeElement.classList.contains("monaco-mouse-cursor-text")).to.be.true + }) + cy.clearEditor() }) it("should show download button with results", () => { // When - cy.typeQuery("select x from long_sequence(10)") - cy.runLine() + cy.typeQueryDirectly("select x from long_sequence(10)") + cy.clickRunIconInLine(1) // Then cy.getByDataHook("download-parquet-button").should("be.visible") diff --git a/e2e/tests/enterprise/oidc.spec.js b/e2e/tests/enterprise/oidc.spec.js index c732cde95..37fc4a7c6 100644 --- a/e2e/tests/enterprise/oidc.spec.js +++ b/e2e/tests/enterprise/oidc.spec.js @@ -57,7 +57,7 @@ describe("OIDC", () => { cy.getByDataHook("auth-login").should("be.visible") cy.getByDataHook("button-sso-continue").should("not.exist") cy.getByDataHook("button-sso-login").should("be.visible") - cy.getByDataHook("button-sso-login").contains("Continue with SSO") + cy.getByDataHook("button-sso-login").contains("Single Sign-On (SSO)") cy.getEditor().should("not.exist") }) @@ -220,7 +220,7 @@ describe("OIDC", () => { cy.getByDataHook("auth-login").should("be.visible") cy.getByDataHook("button-sso-continue").should("not.exist") cy.getByDataHook("button-sso-login").should("be.visible") - cy.getByDataHook("button-sso-login").contains("Continue with SSO") + cy.getByDataHook("button-sso-login").contains("Single Sign-On (SSO)") cy.getEditor().should("not.exist") }) @@ -309,7 +309,7 @@ describe("OIDC", () => { cy.getByDataHook("auth-login").should("be.visible") cy.getByDataHook("button-sso-continue").should("not.exist") cy.getByDataHook("button-sso-login").should("be.visible") - cy.getByDataHook("button-sso-login").contains("Continue with SSO") + cy.getByDataHook("button-sso-login").contains("Single Sign-On (SSO)") cy.getEditor().should("not.exist") cy.getByDataHook("button-sso-login").click() diff --git a/public/assets/grid-bg.webp b/public/assets/grid-bg.webp new file mode 100644 index 000000000..de57caad8 Binary files /dev/null and b/public/assets/grid-bg.webp differ diff --git a/public/assets/login-background.svg b/public/assets/login-background.svg new file mode 100644 index 000000000..2e48616c6 --- /dev/null +++ b/public/assets/login-background.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/plugs.svg b/public/assets/plugs.svg new file mode 100644 index 000000000..cba810716 --- /dev/null +++ b/public/assets/plugs.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/questdb-logo-3d.png b/public/assets/questdb-logo-3d.png new file mode 100644 index 000000000..f9658ccdd Binary files /dev/null and b/public/assets/questdb-logo-3d.png differ diff --git a/public/assets/questdb.svg b/public/assets/questdb.svg new file mode 100644 index 000000000..1fc58589f --- /dev/null +++ b/public/assets/questdb.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/assets/whoops.svg b/public/assets/whoops.svg new file mode 100644 index 000000000..c99c0c74e --- /dev/null +++ b/public/assets/whoops.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/fonts/PPFormula-SemiExtendedBold.otf b/public/fonts/PPFormula-SemiExtendedBold.otf new file mode 100644 index 000000000..ce279ac10 Binary files /dev/null and b/public/fonts/PPFormula-SemiExtendedBold.otf differ diff --git a/public/fonts/PPFormula-SemiExtendedBold.ttf b/public/fonts/PPFormula-SemiExtendedBold.ttf new file mode 100644 index 000000000..7f59fd8fa Binary files /dev/null and b/public/fonts/PPFormula-SemiExtendedBold.ttf differ diff --git a/public/fonts/PPFormula-SemiExtendedBold.woff b/public/fonts/PPFormula-SemiExtendedBold.woff new file mode 100644 index 000000000..91a05dbaa Binary files /dev/null and b/public/fonts/PPFormula-SemiExtendedBold.woff differ diff --git a/public/fonts/PPFormula-SemiExtendedBold.woff2 b/public/fonts/PPFormula-SemiExtendedBold.woff2 new file mode 100644 index 000000000..430235c28 Binary files /dev/null and b/public/fonts/PPFormula-SemiExtendedBold.woff2 differ diff --git a/src/components/CenteredLayout/index.tsx b/src/components/CenteredLayout/index.tsx index 5d651cdc2..7ab794e51 100644 --- a/src/components/CenteredLayout/index.tsx +++ b/src/components/CenteredLayout/index.tsx @@ -1,14 +1,87 @@ import React from "react" import styled from "styled-components" +import { Text } from "../Text" +import { useSettings } from "../../providers" const Root = styled.div` - display: grid; - place-items: center; - height: 100vh; - background: ${({ theme }) => theme.color.backgroundDarker}; - color: ${({ theme }) => theme.color.foreground}; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; + margin-bottom: auto; + overflow-y: auto; + background: ${({ theme }) => theme.color.loginBackground}; ` -export const CenteredLayout = ({ children }: { children: React.ReactNode }) => ( - {children} -) +const Main = styled.div` + margin-top: auto; + position: relative; +` + +const GridBackground = styled.img` + position: absolute; + z-index: 0; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; +` + +const MainContent = styled.div` + position: relative; + z-index: 1; +` + +const Footer = styled.div` + text-align: center; + align-items: center; + display: flex; + gap: 2rem; + margin-bottom: 2rem; + margin-top: auto; +` + +const VersionBadge = styled.div` + display: flex; + padding: 0.6rem 1.1rem; + justify-content: center; + align-items: center; + border-radius: 0.4rem; + border: 0.075rem solid #521427; + background: #290a13; +` + +export const CenteredLayout = ({ children }: { children: React.ReactNode }) => { + const { settings } = useSettings() + return ( + +
+
+ +
+ ) +} diff --git a/src/components/Form/FormInput/index.tsx b/src/components/Form/FormInput/index.tsx index 35189ca7a..c87ced709 100644 --- a/src/components/Form/FormInput/index.tsx +++ b/src/components/Form/FormInput/index.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useState } from "react" import { useFormContext } from "react-hook-form" -import styled from "styled-components" +import styled, { css } from "styled-components" import { Button } from "../../Button" import { Input as UnstyledInput } from "../../Input" import { Eye, EyeOff } from "@styled-icons/remix-line" @@ -14,9 +14,14 @@ export type FormInputProps = React.InputHTMLAttributes & { autoComplete?: string } -const Wrapper = styled.div<{ autoComplete: FormInputProps["autoComplete"] }>` +const Wrapper = styled.div<{ + autoComplete: FormInputProps["autoComplete"] + type: FormInputProps["type"] +}>` display: flex; width: 100%; + position: relative; + align-items: center; ${(props) => props.autoComplete === "off" && ` @@ -26,22 +31,46 @@ const Wrapper = styled.div<{ autoComplete: FormInputProps["autoComplete"] }>` display: none !important; } `} + ${(props) => + props.type === "password" && + ` + border-radius: 8px; + `} + input:-webkit-autofill, + input:-webkit-autofill:hover, + input:-webkit-autofill:focus, + input:-webkit-autofill:active { + -webkit-transition: "color 9999s ease-out, background-color 9999s ease-out"; + -webkit-transition-delay: 9999s; + } ` -const Input = styled(UnstyledInput)` +const Input = styled(UnstyledInput)< + FormInputProps & { $inputType: FormInputProps["type"] } +>` ${(props) => props.disabled && `opacity: 0.7;`} + ${({ $inputType }) => + $inputType === "password" && + css` + width: calc(100% + 3.2rem); + padding-right: 4.2rem !important; + `} ` -const ToggleButton = styled(Button)<{ last?: boolean }>` +const ToggleButton = styled(Button)` cursor: pointer; border-radius: 0; + position: absolute; + right: 1.2rem; + padding: 0; - ${(props) => - props.last && - ` - border-top-right-radius: 0.4rem; - border-bottom-right-radius: 0.4rem; - `} + &:hover { + background: transparent !important; + + svg { + color: ${({ theme }) => theme.color.foreground}; + } + } ` export const FormInput = ({ @@ -69,7 +98,7 @@ export const FormInput = ({ }, []) return ( - + {type === "password" && ( - {passwordShown ? : } + {passwordShown ? : } )} diff --git a/src/components/Form/FormItem/index.tsx b/src/components/Form/FormItem/index.tsx index abf9f2ff7..a216ee102 100644 --- a/src/components/Form/FormItem/index.tsx +++ b/src/components/Form/FormItem/index.tsx @@ -1,6 +1,8 @@ import React from "react" import styled from "styled-components" +import { ErrorWarning } from "@styled-icons/remix-fill" import { useFormContext, FieldError } from "react-hook-form" +import { Box } from "../../../components/Box" import { Text } from "../../../components/Text" type Props = { @@ -50,6 +52,10 @@ const AfterLabel = styled.span` color: ${({ theme }) => theme.color.gray2}; ` +const ErrorIcon = styled(ErrorWarning)` + color: ${({ theme }) => theme.color.red}; +` + export const FormItem = ({ name, label, @@ -86,12 +92,21 @@ export const FormItem = ({ !error && helperText && (typeof helperText === "string" ? ( - {helperText} + + {helperText} + ) : ( helperText ))} - {name && error && {error.message}} + {name && error && ( + + + + {error.message} + + + )} ) } diff --git a/src/modules/OAuth2/views/error.tsx b/src/modules/OAuth2/views/error.tsx deleted file mode 100644 index 7fd49ec45..000000000 --- a/src/modules/OAuth2/views/error.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from "react" -import { CenteredLayout, Text, Button, Box } from "../../../components" -import { User } from "@styled-icons/remix-line" - -export const Error = ({ - basicAuthEnabled, - errorMessage, - onLogout, -}: { - basicAuthEnabled: boolean - errorMessage?: string - onLogout: () => void -}) => { - return ( - - - {errorMessage} - {!basicAuthEnabled && ( - - )} - - - ) -} diff --git a/src/modules/OAuth2/views/login.tsx b/src/modules/OAuth2/views/login.tsx index 2c175992e..e5155ccee 100644 --- a/src/modules/OAuth2/views/login.tsx +++ b/src/modules/OAuth2/views/login.tsx @@ -1,6 +1,8 @@ -import React, { useEffect } from "react" -import styled from "styled-components" -import { User } from "@styled-icons/remix-line" +import React, { useState } from "react" +import styled, { css, useTheme, createGlobalStyle } from "styled-components" +import { User, Building, Close } from "@styled-icons/remix-line" +import { ErrorWarning } from "@styled-icons/remix-fill" +import { XSquare } from "@styled-icons/boxicons-solid" import Joi from "joi" import { Text, Form, Button } from "../../../components" import { setValue } from "../../../utils/localStorage" @@ -8,137 +10,246 @@ import { StoreKey } from "../../../utils/localStorage/types" import { useSettings } from "../../../providers" import { getSSOUserNameWithClientID } from "../utils" import { RawDqlResult } from "utils/questdb/types" +import { LoadingSpinner } from "../../../components/LoadingSpinner" +import { Box } from "../../../components/Box" -const Header = styled.div` +const LoginFontStyles = createGlobalStyle` + @font-face { + font-family: 'PPFormula'; + src: url('/fonts/PPFormula-SemiExtendedBold.woff2') format('woff2'), + url('/fonts/PPFormula-SemiExtendedBold.woff') format('woff'), + url('/fonts/PPFormula-SemiExtendedBold.ttf') format('truetype'); + font-weight: 700; + font-style: normal; + font-display: swap; + } +` + +const LoginContainer = styled.div` + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + background: ${({ theme }) => theme.color.loginBackground}; + overflow-y: auto; +` + +const LoginBackground = styled.img` position: absolute; + top: 1%; + left: 0; width: 100%; - padding: 30px; + max-height: 100vh; + object-fit: cover; + z-index: 0; + pointer-events: none; + user-select: none; ` -const ErrorContainer = styled.div<{ hasError?: string }>` - @keyframes smooth-appear { - to { - transform: translateY(0); - opacity: 1; - } + +const LogoContainer = styled.div` + padding: 2.4rem 4.8rem; + display: flex; + justify-content: center; + align-items: center; + border-bottom: 1px solid ${({ theme }) => theme.color.selection}; +` + +const QuestDBLogo = styled.img` + display: block; + width: 48px; + height: 48px; +` + +const PlugsContainer = styled.div` + width: 4.8rem; + height: 4.8rem; + padding: 1.2rem; + border-radius: 0.4rem; + background: rgba(220, 40, 40, 0.64); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + img { + width: 24px; + height: 24px; + flex-shrink: 0; } +` - @keyframes smooth-disappear { - to { - transform: translateY(10px); - opacity: 0; - } +const CloseContainer = styled.div` + border: 1px solid #6b7280; + border-radius: 4px; + padding: 1.2rem; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + &:hover { + background: #6b7280; } +` - background: gray; +const Container = styled.div<{ $hasRedirectError: boolean }>` + position: relative; + z-index: 1; + margin: ${({ $hasRedirectError }) => ($hasRedirectError ? "0 auto" : "auto")}; + background: ${({ theme }) => theme.color.backgroundDarker}; + width: 560px; + border-radius: 8px; + border: 1px solid ${({ theme }) => theme.color.selection}; + font-size: 16px; +` +const Title = styled.h2` + width: 100%; color: white; - padding: 20px; - margin-top: 10px; - opacity: 0; - transform: translateY(10px); - border-radius: 10px; + font-family: "PPFormula", "Open Sans", sans-serif; + font-weight: 700; + font-size: 33.75px; text-align: center; + margin: 0; + margin-top: 3.2rem; +` - ${({ hasError }) => - hasError - ? ` - animation: smooth-appear 0.5s ease forwards; - ` - : `animation: smooth-disappear 0.5s ease forwards;`} +const FormBody = styled.div` + padding: 3.2rem 4.8rem; + display: flex; + flex-direction: column; + gap: 2rem; ` -const Container = styled.div` - margin-left: auto; - margin-right: auto; - margin-top: 4%; - width: 500px; - font-size: 16px; - transition: height 10s ease; + +const Separator = styled.div` + height: 1px; + background: ${({ theme }) => theme.color.selection}; + width: 100%; ` -const Title = styled.h2` - color: white; - text-align: start; - font-weight: 600; + +const FormFooter = styled.div` + padding: 2.4rem 4.8rem; + display: flex; + justify-content: center; ` const SSOCard = styled.div` + display: flex; + flex-direction: column; + align-items: center; button { padding-top: 2rem; padding-bottom: 2rem; width: 100%; margin-bottom: 10px; } - margin-bottom: 10px; + margin: 3.2rem 4.8rem 0 4.8rem; + gap: 2rem; ` -const Card = styled.div<{ hasError?: string }>` +const Card = styled.div` border-radius: ${({ theme }) => theme.borderRadius}; - - @keyframes horizontal-shaking { - 0% { - transform: translateX(0); - } - 25% { - transform: translateX(5px); - } - 50% { - transform: translateX(-5px); - } - 75% { - transform: translateX(5px); - } - 100% { - transform: translateX(0); - } - } + transition: height 0.5s ease; button[type="submit"] { + background: ${({ theme }) => theme.color.pinkDarker}; + border-color: ${({ theme }) => theme.color.pinkDarker}; padding-top: 2rem; padding-bottom: 2rem; border-radius: 5px; width: 100%; - margin-top: 40px; - } - ${({ hasError }) => - hasError && - ` - button[type="submit"] { - animation: horizontal-shaking 0.3s ease-in-out; + &:hover { + background: ${({ theme }) => theme.color.pinkPrimary}; + border-color: ${({ theme }) => theme.color.pinkPrimary}; } - `} - - button { - padding-top: 2rem; - padding-bottom: 2rem; - border-radius: 0 5px 5px 0; } - input[name="password"] { + button { padding-top: 2rem; padding-bottom: 2rem; - border-radius: 5px 0 0 5px; + border-radius: 5px; } input { - padding-top: 2rem; - padding-bottom: 2rem; - border-radius: 5px; - box-shadow: 0 0 0 30px #44475a inset !important; - -webkit-text-fill-color: white; + display: flex; + padding: 12px; + height: 4.5rem; + align-items: center; + align-self: stretch; + border-radius: 8px; + border: 1px solid #6b7280; + background: ${({ theme }) => theme.color.background}; + font-size: 1.4rem; + line-height: 1.5; + + &:focus, + &:active, + &:focus-visible { + background: ${({ theme }) => theme.color.background} !important; + border-color: ${({ theme }) => theme.color.pinkPrimary} !important; + } } label { - margin-top: 20px; + font-size: 1.6rem; + font-family: monospace; + text-transform: uppercase; } ` -const StyledButton = styled(Button)` - padding-top: 2rem; - padding-bottom: 2rem; +const ErrorContainer = styled.div` + display: flex; + align-items: center; + gap: 1.5rem; + padding: 1.2rem 2.4rem 1.2rem 1.8rem; + border-radius: 0.8rem; + border: 1.5px solid rgba(220, 40, 40, 0.72); + border-left: 6px solid rgba(220, 40, 40, 0.72); +` + +const RedirectErrorContainer = styled(ErrorContainer)` + margin-top: auto; + margin-bottom: 3.2rem; + position: relative; + z-index: 1; + border-color: rgba(220, 40, 40); + width: 560px; + background: ${({ theme }) => theme.color.backgroundDarker}; +` + +const StyledButton = styled(Button)<{ skin: string }>` + margin: 0 !important; + ${({ skin }) => + skin === "primary" && + css` + background: ${({ theme }) => theme.color.pinkDarker} !important; + border-color: ${({ theme }) => theme.color.pinkDarker} !important; + + &:hover { + background: ${({ theme }) => theme.color.pinkPrimary} !important; + border-color: ${({ theme }) => theme.color.pinkPrimary} !important; + } + `} + + ${({ skin }) => + skin === "secondary" && + css` + border-radius: 4px !important; + border: 1px solid #6b7280 !important; + background: transparent !important; + + &:hover { + background: #6b7280 !important; + border-color: #6b7280 !important; + } + `} ` const Line = styled.div` position: relative; text-align: center; + margin-top: 1.2rem; + width: 100%; &:before { content: ""; @@ -148,6 +259,12 @@ const Line = styled.div` width: 100%; height: 1px; background: ${({ theme }) => theme.color.selection}; + background: linear-gradient( + 90deg, + rgba(55, 65, 81, 0) 0%, + #374151 50%, + rgba(55, 65, 81, 0) 100% + ); } ` @@ -172,21 +289,34 @@ const LineText = styled(Text)` position: relative; z-index: 1; background: ${({ theme }) => theme.color.backgroundDarker}; - padding: 0 1.5rem; + padding: 0 2.4rem; + font-family: monospace; + text-transform: uppercase; ` const Footer = styled.div` - width: 700px; text-align: center; - padding: 20px; - margin-top: 40px; margin-right: auto; margin-left: auto; + align-items: center; + display: flex; + gap: 2rem; + margin: 2rem 0; +` + +const VersionBadge = styled.div` + display: flex; + padding: 0.6rem 1.1rem; + justify-content: center; + align-items: center; + border-radius: 0.4rem; + border: 0.075rem solid #521427; + background: #290a13; ` const schema = Joi.object({ username: Joi.string().required().messages({ - "string.empty": "User name is required", + "string.empty": "Username is required", }), password: Joi.string().required().messages({ "string.empty": "Password is required", @@ -198,17 +328,28 @@ type FormValues = { username: string; password: string } export const Login = ({ onOAuthLogin, onBasicAuthSuccess, + errorTitle: redirectErrorTitle, + errorMessage: redirectErrorMessage, + isDisconnection, + resetErrors, }: { onOAuthLogin: (loginWithDifferentAccount?: boolean) => void onBasicAuthSuccess: () => void + errorTitle?: string + errorMessage?: string + isDisconnection?: boolean + resetErrors: () => void }) => { const { settings } = useSettings() + const theme = useTheme() const isEE = settings["release.type"] === "EE" const [errorMessage, setErrorMessage] = React.useState() const ssoUsername = settings["acl.oidc.enabled"] && settings["acl.oidc.client.id"] ? getSSOUserNameWithClientID(settings["acl.oidc.client.id"]) : "" + const version = settings["release.version"] + const [loading, setLoading] = useState(false) const httpBasicAuthStrategy = isEE ? { @@ -235,6 +376,8 @@ export const Login = ({ } const handleSubmit = async (values: FormValues) => { + resetErrors() + setLoading(true) const { username, password } = values try { const response = await fetch( @@ -249,110 +392,168 @@ export const Login = ({ await httpBasicAuthStrategy.store(response, username, password) return onBasicAuthSuccess() } else if (response.status === 401) { - setErrorMessage("Invalid user name or password") + setErrorMessage("Invalid username or password") } else if (response.status === 403) { - setErrorMessage("Unauthorized to use the Web Console") + setErrorMessage( + "You are not authorized to use the Web Console. Contact your account administrator.", + ) } else { - setErrorMessage("Login failed, status code: " + response.status) + setErrorMessage("Error occurred while trying to login") } } catch (e) { setErrorMessage("Error occurred while trying to login") + } finally { + setLoading(false) } } - useEffect(() => { - setTimeout(() => { - setErrorMessage(undefined) - }, 5000) - }, [errorMessage]) - return settings["acl.basic.auth.realm.enabled"] ? null : ( -
-
- - QuestDB logotype - -
- - {/* - The title should include server name, to help users - orient in case there are multiple instances around. This name - will eventually be provided via "settings" endpoint too. If name - is absent, we should display generic text as the title contributes to - the page layout. - */} - {settings["acl.oidc.enabled"] && ( - <> - Single Sign-On - - {!!ssoUsername && ( + <> + + +
+ + or + + + )} + + name="login" + onSubmit={handleSubmit} + defaultValues={{}} + validationSchema={schema} + > + + + + + + + + {errorMessage && ( + + + + + Sign in failed. + + + {errorMessage} + + + + )} + + + + + {loading ? ( + + ) : ( + "Sign In" + )} + + + + + +
+ + Copyright © {new Date().getFullYear()} QuestDB. All rights + reserved. + + + + QuestDB {isEE ? "Enterprise" : ""} {version} + + +
+ + ) } diff --git a/src/providers/AuthProvider.tsx b/src/providers/AuthProvider.tsx index 90a1d0e7a..7a3de8c77 100644 --- a/src/providers/AuthProvider.tsx +++ b/src/providers/AuthProvider.tsx @@ -23,7 +23,6 @@ import { import { eventBus } from "../modules/EventBus" import { EventType } from "../modules/EventBus/types" import { ErrorResult } from "../utils" -import { Error } from "../modules/OAuth2/views/error" import { Login } from "../modules/OAuth2/views/login" import { Settings } from "./SettingsProvider/types" import { useSettings } from "./SettingsProvider" @@ -31,7 +30,17 @@ import { ssoAuthState } from "../modules/OAuth2/ssoAuthState" type ContextProps = { sessionData?: Partial - logout: (promptForLogin?: boolean) => void + logout: ({ + promptForLogin, + errorTitle, + errorMessage, + isDisconnection, + }?: { + promptForLogin?: boolean + errorTitle?: string + errorMessage?: string + isDisconnection?: boolean + }) => void refreshAuthToken: ( settings: Settings, refreshToken: string | undefined, @@ -42,13 +51,22 @@ type ContextProps = { enum View { ready, loading, - error, login, } -type State = { view: View; errorMessage?: string } +type State = { + view: View + errorTitle?: string + errorMessage?: string + isDisconnection?: boolean +} -const initialState: { view: View; errorMessage?: string } = { +const initialState: { + view: View + errorTitle?: string + errorMessage?: string + isDisconnection?: boolean +} = { view: View.loading, } @@ -77,9 +95,6 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { const { settings } = useSettings() const [sessionData, setSessionData] = useState(undefined) - const [errorMessage, setErrorMessage] = useState( - undefined, - ) const [state, dispatch] = useReducer(reducer, initialState) const setAuthToken = (tokenResponse: AuthPayload, settings: Settings) => { @@ -103,8 +118,8 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { } else { const error = tokenResponse as unknown as OAuth2Error // display error message - dispatch({ - view: View.error, + logout({ + errorTitle: "Something went wrong.", errorMessage: error.error_description ?? "Error logging in. Please try again.", }) @@ -144,7 +159,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { if (!isNaN(count) && count >= 5) { // redirect to /logout and force user authentication to avoid infinite loop removeValue(StoreKey.OAUTH_REDIRECT_COUNT) - logout(true) + logout({ promptForLogin: true }) } else { setValue( StoreKey.OAUTH_REDIRECT_COUNT, @@ -184,7 +199,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { const stateParam = urlParams.get("state") if (!stateParam || state !== stateParam) { // state is missing or there is a mismatch, user has to re-authenticate - logout(true) + logout({ promptForLogin: true }) return } } @@ -212,10 +227,11 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { redirectToAuthorizationUrl() } else { // If the error is not in response for a silent authorization code request, display the error - setErrorMessage( - oauth2Error.error + ": " + oauth2Error.error_description, - ) - dispatch({ view: View.error }) + logout({ + errorTitle: "Something went wrong.", + errorMessage: + oauth2Error.error + ": " + oauth2Error.error_description, + }) } } else if (ssoUsername && !getValue(StoreKey.REST_TOKEN)) { // No REST token, so it is a page reload for an SSO user @@ -290,8 +306,19 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { }) } - const logout = (promptForLogin?: boolean) => { + const logout = ({ + promptForLogin, + errorTitle, + errorMessage, + isDisconnection, + }: { + promptForLogin?: boolean + errorTitle?: string + errorMessage?: string + isDisconnection?: boolean + } = {}) => { ssoAuthState.clearAuthPayload() + setSessionData(undefined) removeValue(StoreKey.OAUTH_PROMPT) removeValue(StoreKey.REST_TOKEN) removeValue(StoreKey.BASIC_AUTH_HEADER) @@ -299,7 +326,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { removeSSOUserNameWithClientID(settings["acl.oidc.client.id"]) } destroyServerSession() - dispatch({ view: View.login }) + dispatch({ view: View.login, errorTitle, errorMessage, isDisconnection }) } useEffect(() => { @@ -318,9 +345,11 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { errorPayload && errorPayload.error.match(/Access denied.* \[HTTP]/gm) ) { - dispatch({ - view: View.error, - errorMessage: "Unauthorized to use the Web Console.", + logout({ + errorTitle: "Oops. You've been disconnected.", + errorMessage: + "You are not authorized to use the Web Console. You may try login again or contact your account administrator.", + isDisconnection: true, }) } }, @@ -343,13 +372,6 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { {children} ), - [View.error]: () => ( - - ), [View.login]: () => ( { @@ -360,6 +382,16 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { onBasicAuthSuccess={() => { dispatch({ view: View.ready }) }} + resetErrors={() => + dispatch({ + errorTitle: undefined, + errorMessage: undefined, + isDisconnection: undefined, + }) + } + errorTitle={state.errorTitle} + errorMessage={state.errorMessage} + isDisconnection={state.isDisconnection ?? false} /> ), } diff --git a/src/providers/SettingsProvider/index.tsx b/src/providers/SettingsProvider/index.tsx index 5d3fef219..2fe13d147 100644 --- a/src/providers/SettingsProvider/index.tsx +++ b/src/providers/SettingsProvider/index.tsx @@ -5,13 +5,13 @@ import React, { useReducer, useState, } from "react" +import styled from "styled-components" import { ConsoleConfig, Settings, Warning } from "./types" import { CenteredLayout, Box, Text, Button } from "../../components" import { Refresh } from "@styled-icons/remix-line" import { setValue } from "../../utils/localStorage" import { StoreKey } from "../../utils/localStorage/types" import { Preferences } from "../../utils" -import QuestDBLogo from "./QuestDBLogo" enum View { loading = 0, @@ -52,15 +52,48 @@ const SettingContext = createContext<{ }), }) +const TextContainer = styled.div` + display: flex; + flex-direction: column; + padding: 2.4rem; + gap: 2.8rem; +` +const RefreshButton = styled(Button)` + padding: 1.2rem; + height: unset !important; +` + +const Whoops = styled.img` + width: auto; + height: auto; + + @media (max-width: 1000px) { + width: 600px; + height: auto; + } +` + const connectionError = ( - <> - Error connecting to the database. -
- Please, check if the server is running correctly. - + + + It appears we can't connect to the database. + + + Please, check if the server is running correctly. + + ) -const consoleConfigError = <>Error loading the console configuration file +const consoleConfigError = ( + + + It appears we can't connect to the database. + + + Error loading the console configuration file. + + +) export const SettingsProvider = ({ children, @@ -90,22 +123,35 @@ export const SettingsProvider = ({ ), [View.error]: () => ( - - - + + + QuestDB logo - - Error connecting to the database. -
- Please, check if the server is running correctly. -
- + + + {state.errorMessage ?? ( + + + It appears we can't connect to the database. + + + Please, check if the server is running correctly. + + + )} + } + onClick={() => window.location.reload()} + > + Retry + +
), @@ -196,9 +242,17 @@ export const SettingsProvider = ({ if (consoleConfig) { setConsoleConfig(consoleConfig) } + + if (!settings || !consoleConfig) { + throw new Error("Failed to fetch settings from the server") + } } - void fetchAll().then(() => dispatch({ view: View.ready })) + void fetchAll() + .then(() => dispatch({ view: View.ready })) + .catch((_err) => { + // view should already be set to error + }) }, []) return <>{views[state.view]()} diff --git a/src/theme/index.ts b/src/theme/index.ts index 0c510a9b0..618212065 100644 --- a/src/theme/index.ts +++ b/src/theme/index.ts @@ -41,6 +41,7 @@ const color: ColorShape = { comment: "#6272a4", red: "#ff5555", redDark: "#5a1d1d", + loginBackground: "#1D070E", orange: "#ffb86c", yellow: "#f1fa8c", green: "#50fa7b", diff --git a/src/types/styled.d.ts b/src/types/styled.d.ts index 87e691c8b..4c3837750 100644 --- a/src/types/styled.d.ts +++ b/src/types/styled.d.ts @@ -40,6 +40,7 @@ export type ColorShape = { comment: string red: string redDark: string + loginBackground: string orange: string yellow: string green: string