diff --git a/app/src/__tests__/components/auth/AuthPage.spec.tsx b/app/src/__tests__/components/auth/AuthPage.spec.tsx index d6d643352..4ea75926e 100644 --- a/app/src/__tests__/components/auth/AuthPage.spec.tsx +++ b/app/src/__tests__/components/auth/AuthPage.spec.tsx @@ -63,6 +63,6 @@ describe('AuthPage ', () => { const input = getByLabelText('Enter your password in the field above'); fireEvent.change(input, { target: { value: 'test-pw' } }); fireEvent.click(getByText('Submit')); - expect(await findByText('oops, that password is incorrect')).toBeInTheDocument(); + expect(await findByText('failed to connect')).toBeInTheDocument(); }); }); diff --git a/app/src/__tests__/store/authStore.spec.ts b/app/src/__tests__/store/authStore.spec.ts index 80abeb6b4..826228c22 100644 --- a/app/src/__tests__/store/authStore.spec.ts +++ b/app/src/__tests__/store/authStore.spec.ts @@ -37,9 +37,7 @@ describe('AuthStore', () => { if (desc.methodName === 'GetInfo') throw new Error('test-err'); return undefined as any; }); - await expect(store.login('test-pw')).rejects.toThrow( - 'oops, that password is incorrect', - ); + await expect(store.login('test-pw')).rejects.toThrow('failed to connect'); expect(store.credentials).toBe(''); }); diff --git a/app/src/components/auth/AuthPage.tsx b/app/src/components/auth/AuthPage.tsx index f1782b330..e7d04e829 100644 --- a/app/src/components/auth/AuthPage.tsx +++ b/app/src/components/auth/AuthPage.tsx @@ -4,7 +4,7 @@ import styled from '@emotion/styled'; import { ReactComponent as LogoImage } from 'assets/images/logo.svg'; import { usePrefixedTranslation } from 'hooks'; import { useStore } from 'store'; -import { Background, Button, HeaderOne, Input } from 'components/base'; +import { Background, Button, ChevronDown, ChevronUp, HeaderOne } from 'components/base'; const Styled = { Wrapper: styled.div` @@ -33,22 +33,57 @@ const Styled = { text-align: center; `, Form: styled.form` + max-width: 550px; display: flex; flex-direction: column; align-items: center; `, - Label: styled.label` - margin: 10px 0 80px; + Password: styled.input` + font-family: ${props => props.theme.fonts.work.light}; + font-weight: 300; + font-size: ${props => props.theme.sizes.xxl}; + color: ${props => props.theme.colors.offWhite}; + background-color: transparent; + border-width: 0; + border-bottom: 3px solid ${props => props.theme.colors.offWhite}; + padding: 5px; + text-align: center; + width: 100%; + + &:active, + &:focus { + outline: none; + background-color: ${props => props.theme.colors.overlay}; + border-bottom-color: ${props => props.theme.colors.white}; + } + + &::placeholder { + color: ${props => props.theme.colors.gray}; + } `, + Label: styled.label``, ErrMessage: styled.div` width: 100%; - margin: 0 0 80px; + display: inline-block; padding: 5px 0; background-color: ${props => props.theme.colors.pink}; color: ${props => props.theme.colors.offWhite}; text-align: center; `, + ErrDetail: styled.div` + width: 100%; + display: inline-block; + padding: 5px 0; + color: ${props => props.theme.colors.offWhite}; + text-align: center; + `, + ErrDetailToggle: styled(Button)` + width: 100%; + padding: 5px 0; + background-color: transparent; + `, Submit: styled(Button)` + margin-top: 80px; background-color: transparent; `, }; @@ -58,10 +93,17 @@ const AuthPage: React.FC = () => { const store = useStore(); const [pass, setPass] = useState(''); const [error, setError] = useState(''); + const [errorDetailLit, setErrorDetailLit] = useState(''); + const [errorDetailLnd, setErrorDetailLnd] = useState(''); + const [errorDetailVisible, setErrorDetailVisible] = useState(false); + const [showDetailButton, setShowDetailButton] = useState(true); const handleChange = (e: React.ChangeEvent) => { setPass(e.target.value); setError(''); + setErrorDetailLit(''); + setErrorDetailLnd(''); + setShowDetailButton(false); }; const handleSubmit = async (e: React.FormEvent) => { @@ -70,6 +112,12 @@ const AuthPage: React.FC = () => { await store.authStore.login(pass); } catch (err) { setError(err.message); + const errors = store.authStore.errors; + setErrorDetailLit(errors.litDetail); + setErrorDetailLnd(errors.lndDetail); + + // don't display the detail toggle button if there is nothing to display + setShowDetailButton(errors.litDetail.length > 0 || errors.litDetail.length > 0); } }; @@ -77,7 +125,19 @@ const AuthPage: React.FC = () => { // a UI flicker while validating credentials stored in session storage if (!store.initialized) return null; - const { Wrapper, Logo, Title, Subtitle, Form, Label, ErrMessage, Submit } = Styled; + const { + Wrapper, + Logo, + Title, + Subtitle, + Form, + Password, + Label, + ErrMessage, + ErrDetail, + ErrDetailToggle, + Submit, + } = Styled; return ( @@ -86,7 +146,7 @@ const AuthPage: React.FC = () => { {l('terminal')} {l('subtitle')}
- { onChange={handleChange} /> {error ? ( - {error} + <> + {error} + {errorDetailVisible && errorDetailLit.length > 0 ? ( + {errorDetailLit} + ) : ( + '' + )} + {errorDetailVisible && errorDetailLnd.length > 0 ? ( + {errorDetailLnd} + ) : ( + '' + )} + {showDetailButton ? ( + { + setErrorDetailVisible(!errorDetailVisible); + }} + > + {!errorDetailVisible ? : } + {!errorDetailVisible ? l('showDetail') : l('hideDetail')} + + ) : ( + '' + )} + ) : ( )} diff --git a/app/src/i18n/locales/en-US.json b/app/src/i18n/locales/en-US.json index 78ac05ee1..266567d2a 100644 --- a/app/src/i18n/locales/en-US.json +++ b/app/src/i18n/locales/en-US.json @@ -26,6 +26,8 @@ "cmps.auth.AuthPage.terminal": "Terminal", "cmps.auth.AuthPage.subtitle": "Efficiently manage Lightning node liquidity", "cmps.auth.AuthPage.passLabel": "Enter your password in the field above", + "cmps.auth.AuthPage.showDetail": "Show Detail", + "cmps.auth.AuthPage.hideDetail": "Hide Detail", "cmps.auth.AuthPage.submitBtn": "Submit", "cmps.common.Tile.maximizeTip": "Maximize", "cmps.common.PageHeader.exportTip": "Download CSV", @@ -399,6 +401,12 @@ "cmps.tour.SuccessStep.close": "Close", "stores.authStore.emptyPassErr": "oops, password is required", "stores.authStore.invalidPassErr": "oops, that password is incorrect", + "stores.authStore.noConnectionErr": "failed to connect", + "stores.authStore.walletLockedErr": "oops, wallet is locked", + "stores.authStore.litNotConnected": "Unable to connect to LiT. Please restart litd and try again.", + "stores.authStore.litNotRunning": "LiT is not running.", + "stores.authStore.lndNotRunning": "LND is not running. Please start lnd and try again.", + "stores.authStore.suggestWalletUnlock": " Please ensure that the wallet is unlocked.", "stores.buildSwapView.noChannelsMsg": "You cannot perform a swap without any active channels", "stores.orderFormView.buy": "Bid", "stores.orderFormView.sell": "Ask", @@ -416,7 +424,7 @@ "stores.settingsStore.httpError": "url must start with 'http'", "stores.settingsStore.keyword": "url must contain {{keyword}}", "stores.appView.authErrorTitle": "Your session has expired", - "stores.appView.authErrorMsg": "Please enter you password to continue", + "stores.appView.authErrorMsg": "Please enter your password to continue", "views.fundNewAccountView.amountTooLow": "must be greater than {{accountMinimum}} sats", "views.fundNewAccountView.amountTooHigh": "must be less than wallet balance", "views.fundNewAccountView.lowExpireBlocks": "must be greater than {{blocks}} blocks", diff --git a/app/src/store/stores/authStore.ts b/app/src/store/stores/authStore.ts index d76d00223..27bcc7687 100644 --- a/app/src/store/stores/authStore.ts +++ b/app/src/store/stores/authStore.ts @@ -5,6 +5,18 @@ import { Store } from 'store'; const { l } = prefixTranslation('stores.authStore'); +class SubServerStatus { + disabled: boolean; + error: string; + running: boolean; + + constructor() { + this.disabled = false; + this.error = ''; + this.running = false; + } +} + export default class AuthStore { private _store: Store; @@ -14,6 +26,8 @@ export default class AuthStore { /** the password encoded to base64 */ credentials = ''; + errors = { mainErr: '', litDetail: '', lndDetail: '' }; + constructor(store: Store) { makeAutoObservable(this, {}, { deep: false, autoBind: true }); @@ -31,6 +45,67 @@ export default class AuthStore { Object.values(this._store.api).forEach(api => api.setCredentials(credentials)); } + /** + * Convert exception to error message + */ + async getErrMsg(error: string) { + // determine the main error message + const invalidPassMsg = ['expected 1 macaroon, got 0']; + for (const m in invalidPassMsg) { + const errPos = error.lastIndexOf(invalidPassMsg[m]); + if (error.length - invalidPassMsg[m].length == errPos) { + this.errors.mainErr = l('invalidPassErr'); + break; + } + } + + let walletLocked = false; + if (this.errors.mainErr.length == 0) { + const walletLockedMsg = [ + 'wallet locked, unlock it to enable full RPC access', + 'proxy error with context auth: unknown macaroon to use', + ]; + for (const m in walletLockedMsg) { + const errPos = error.lastIndexOf(walletLockedMsg[m]); + if (error.length - walletLockedMsg[m].length == errPos) { + walletLocked = true; + this.errors.mainErr = l('walletLockedErr'); + break; + } + } + } + + if (this.errors.mainErr.length == 0) this.errors.mainErr = l('noConnectionErr'); + + // get the subserver status message + try { + const serverStatus = await this._store.api.lit.listSubServerStatus(); + // convert the response's nested arrays to an object mapping `subServerName` -> `{ disabled, running, error }` + const status = serverStatus.subServersMap.reduce( + (acc, [serverName, serverStatus]) => ({ ...acc, [serverName]: serverStatus }), + {} as Record, + ); + + // check status + if (status.lit?.error) { + this.errors.litDetail = status.lit.error; + } else if (!status.lit?.running) { + this.errors.litDetail = l('litNotRunning'); + if (walletLocked) this.errors.litDetail += l('suggestWalletUnlock'); + } + + if (status.lnd?.error) { + this.errors.lndDetail = status.lnd.error; + } else if (!status.lnd?.running) { + this.errors.lndDetail = l('lndNotRunning'); + } + } catch (e) { + this.errors.litDetail = l('litNotConnected'); + } + + return this.errors.mainErr; + } + /** * Validate the supplied password and save for later if successful */ @@ -49,8 +124,9 @@ export default class AuthStore { } catch (error) { // clear the credentials if incorrect this.setCredentials(''); - this._store.log.error('incorrect credentials'); - throw new Error(l('invalidPassErr')); + this._store.log.error('connection failure'); + this.errors = { mainErr: '', litDetail: '', lndDetail: '' }; + throw new Error(await this.getErrMsg(error.message)); } }