diff --git a/index.html b/index.html index d9c70b0..03100bf 100644 --- a/index.html +++ b/index.html @@ -115,6 +115,32 @@ layersTitle: 'Layers', copyToClipboardSuccess: 'Copied to clipboard', copyToClipboardError: 'Unable to copy to clipboard', + + loginTitle: 'Login/Register', + loginHeading: 'Existing User', + loginUsernameLabel: 'Username', + loginPasswordLabel: 'Password', + loginSubmit: 'Login', + loginErrorUnknown: 'Unexpected error while logging in', + loginErrorDisabled: 'Logging in is disabled on this server', + loginErrorIncorrect: 'Incorrect username or password', + loginSuccess: 'Logged in successfully', + + registerHeading: 'New User', + registerDescription: `Enter your username and password, along with your registration code. + + You can get a registration code by running /dynmap webregister in-game.`, + registerConfirmPasswordLabel: 'Confirm Password', + registerCodeLabel: 'Registration Code', + registerSubmit: 'Register', + registerErrorUnknown: 'Unexpected error during registration', + registerErrorDisabled: 'Registration is disabled on this server', + registerErrorVerifyFailed: 'The entered passwords do not match', + registerErrorIncorrect: 'Registration failed, please check the entered details are correct', + + logoutTitle: 'Logout', + logoutErrorUnknown: 'Unexpected error while logging out', + logoutSuccess: 'Logged out successfully', }, ui: { diff --git a/src/App.vue b/src/App.vue index 07615ea..7189e1a 100644 --- a/src/App.vue +++ b/src/App.vue @@ -17,6 +17,7 @@ @@ -32,13 +33,16 @@ import {parseUrl} from '@/util'; import {hideSplash, showSplash, showSplashError} from '@/util/splash'; import {MutationTypes} from "@/store/mutation-types"; import {LiveAtlasServerDefinition, LiveAtlasUIElement} from "@/index"; +import LoginModal from "@/components/login/LoginModal.vue"; +import {notify} from "@kyvg/vue3-notification"; export default defineComponent({ name: 'App', components: { Map, Sidebar, - ChatBox + ChatBox, + LoginModal }, setup() { @@ -48,6 +52,7 @@ export default defineComponent({ currentServer = computed(() => store.state.currentServer), configurationHash = computed(() => store.state.configurationHash), chatBoxEnabled = computed(() => store.state.components.chatBox), + loginEnabled = computed(() => store.state.components.login), chatVisible = computed(() => store.state.ui.visibleElements.has('chat')), loggedIn = computed(() => store.state.loggedIn), @@ -79,6 +84,14 @@ export default defineComponent({ return; } + //Show login screen if required + if(e.message === 'login-required') { + hideSplash(); + store.commit(MutationTypes.SHOW_UI_MODAL, 'login'); + notify('Login required'); + return; + } + const error = `Failed to load server configuration for '${store.state.currentServer!.id}'`; console.error(`${error}:`, e); showSplashError(`${error}\n${e}`, false, ++loadingAttempts.value); @@ -163,6 +176,12 @@ export default defineComponent({ await loadConfiguration(); } }); + watch(loggedIn, async () => { + if(!loading.value) { + console.log('Login state changed. Reloading configuration'); + await loadConfiguration(); + } + }) onMounted(() => loadConfiguration()); onBeforeUnmount(() => store.dispatch(ActionTypes.STOP_UPDATES, undefined)); @@ -184,6 +203,7 @@ export default defineComponent({ return { chatBoxEnabled, chatVisible, + loginEnabled, } }, }); diff --git a/src/assets/icons/login.svg b/src/assets/icons/login.svg new file mode 100644 index 0000000..ec77850 --- /dev/null +++ b/src/assets/icons/login.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/logout.svg b/src/assets/icons/logout.svg new file mode 100644 index 0000000..5bed3ae --- /dev/null +++ b/src/assets/icons/logout.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/Map.vue b/src/components/Map.vue index eca4fb2..48e37a5 100644 --- a/src/components/Map.vue +++ b/src/components/Map.vue @@ -25,9 +25,12 @@ + + + @@ -48,6 +51,7 @@ import LiveAtlasLeafletMap from "@/leaflet/LiveAtlasLeafletMap"; import {LoadingControl} from "@/leaflet/control/LoadingControl"; import MapContextMenu from "@/components/map/MapContextMenu.vue"; import {Coordinate, LiveAtlasPlayer} from "@/index"; +import LoginControl from "@/components/map/control/LoginControl.vue"; export default defineComponent({ components: { @@ -59,7 +63,8 @@ export default defineComponent({ ClockControl, LinkControl, ChatControl, - LogoControl + LogoControl, + LoginControl }, setup() { @@ -75,6 +80,7 @@ export default defineComponent({ clockControlEnabled = computed(() => store.getters.clockControlEnabled), linkControlEnabled = computed(() => store.state.components.linkControl), chatBoxEnabled = computed(() => store.state.components.chatBox), + loginEnabled = computed(() => store.state.components.login), logoControls = computed(() => store.state.components.logoControls), currentWorld = computed(() => store.state.currentWorld), @@ -102,6 +108,7 @@ export default defineComponent({ clockControlEnabled, linkControlEnabled, chatBoxEnabled, + loginEnabled, logoControls, followTarget, diff --git a/src/components/Modal.vue b/src/components/Modal.vue new file mode 100644 index 0000000..55c6c1d --- /dev/null +++ b/src/components/Modal.vue @@ -0,0 +1,101 @@ + + + + + + + diff --git a/src/components/login/LoginForm.vue b/src/components/login/LoginForm.vue new file mode 100644 index 0000000..6e1302a --- /dev/null +++ b/src/components/login/LoginForm.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/src/components/login/LoginModal.vue b/src/components/login/LoginModal.vue new file mode 100644 index 0000000..31e7dba --- /dev/null +++ b/src/components/login/LoginModal.vue @@ -0,0 +1,70 @@ + + + + + + + diff --git a/src/components/login/RegisterForm.vue b/src/components/login/RegisterForm.vue new file mode 100644 index 0000000..64cb8fa --- /dev/null +++ b/src/components/login/RegisterForm.vue @@ -0,0 +1,163 @@ + + + + + + + diff --git a/src/components/map/control/LoginControl.vue b/src/components/map/control/LoginControl.vue new file mode 100644 index 0000000..f48953d --- /dev/null +++ b/src/components/map/control/LoginControl.vue @@ -0,0 +1,43 @@ + + + diff --git a/src/index.d.ts b/src/index.d.ts index 99e22f1..9449b0f 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -100,6 +100,27 @@ interface LiveAtlasGlobalMessageConfig { layersTitle: string; copyToClipboardSuccess: string; copyToClipboardError: string; + loginTitle: string; + loginHeading: string; + loginUsernameLabel: string; + loginPasswordLabel: string; + loginSubmit: string; + loginErrorUnknown: string; + loginErrorDisabled: string; + loginErrorIncorrect: string; + loginSuccess: string; + registerHeading: string; + registerDescription: string; + registerConfirmPasswordLabel: string; + registerCodeLabel: string; + registerSubmit: string; + registerErrorUnknown: string; + registerErrorDisabled: string; + registerErrorVerifyFailed: string; + registerErrorIncorrect: string; + logoutTitle: string; + logoutErrorUnknown: string; + logoutSuccess: string; } // Messages defined by dynmap configuration responses and can vary per server @@ -122,7 +143,8 @@ interface LiveAtlasUIConfig { playersSearch: boolean; } -export type LiveAtlasUIElement = 'layers' | 'chat' | 'players' | 'maps' | 'settings'; +export type LiveAtlasUIElement = 'layers' | 'chat' | 'players' | 'maps'; +export type LiveAtlasUIModal = 'login' | 'settings'; export type LiveAtlasSidebarSection = 'servers' | 'players' | 'maps'; export type LiveAtlasDimension = 'overworld' | 'nether' | 'end'; @@ -172,6 +194,9 @@ interface LiveAtlasMapProvider { startUpdates(): void; stopUpdates(): void; sendChatMessage(message: string): void; + login(formData: FormData): void; + logout(): void; + register(formData: FormData): void; destroy(): void; getPlayerHeadUrl(entry: HeadQueueEntry): string; diff --git a/src/leaflet/control/LoginControl.ts b/src/leaflet/control/LoginControl.ts new file mode 100644 index 0000000..1ee28a9 --- /dev/null +++ b/src/leaflet/control/LoginControl.ts @@ -0,0 +1,100 @@ +/* + * Copyright 2021 James Lyne + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {Control, ControlOptions, DomEvent, DomUtil} from 'leaflet'; +import {useStore} from "@/store"; +import {MutationTypes} from "@/store/mutation-types"; +import {watch} from "@vue/runtime-core"; + +import "@/assets/icons/login.svg"; +import "@/assets/icons/logout.svg"; +import {computed} from "vue"; +import {ActionTypes} from "@/store/action-types"; +import {notify} from "@kyvg/vue3-notification"; + +export class LoginControl extends Control { + declare options: ControlOptions + private readonly store = useStore(); + private readonly loggedIn = computed(() => this.store.state.loggedIn); + private readonly _button: HTMLButtonElement; + + constructor(options: ControlOptions) { + super(options); + + this._button = DomUtil.create('button', + 'leaflet-control-bottom leaflet-control-button leaflet-control-login') as HTMLButtonElement; + + this._button.type = 'button'; + + this._button.addEventListener('click', async e => { + e.stopPropagation(); + e.preventDefault(); + + await this.handleClick(); + }); + + //Open login on ArrowRight from button + DomEvent.on(this._button,'keydown', async (e: Event) => { + if ((e as KeyboardEvent).key === 'ArrowRight') { + e.stopPropagation(); + e.preventDefault(); + + await this.handleClick(); + } + }); + + watch(this.loggedIn, () => { + this.update(); + }); + + watch(this.store.state.ui, newValue => { + this._button.setAttribute('aria-expanded', (newValue.visibleModal === 'login').toString()); + }, { + deep: true, + }); + + this.update(); + } + + onAdd() { + return this._button; + } + + private update() { + this._button.title = this.loggedIn.value + ? this.store.state.messages.logoutTitle : this.store.state.messages.loginTitle; + this._button.innerHTML = ` + + + `; + } + + private async handleClick() { + const logoutSuccess = computed(() => this.store.state.messages.logoutSuccess), + logoutError = computed(() => this.store.state.messages.logoutErrorUnknown); + + if (this.loggedIn.value) { + try { + await this.store.dispatch(ActionTypes.LOGOUT, undefined); + notify(logoutSuccess.value); + } catch(e) { + notify(logoutError.value); + } + } else { + this.store.commit(MutationTypes.SHOW_UI_MODAL, 'login'); + } + } +} diff --git a/src/providers/DynmapMapProvider.ts b/src/providers/DynmapMapProvider.ts index d54fc04..9301f54 100644 --- a/src/providers/DynmapMapProvider.ts +++ b/src/providers/DynmapMapProvider.ts @@ -628,9 +628,12 @@ export default class DynmapMapProvider extends MapProvider { const response = await DynmapMapProvider.getJSON(this.config.dynmap!.configuration, this.configurationAbort.signal); - if (response.error === 'login-required') { - throw new Error("Login required"); - } else if (response.error) { + if(response.error === 'login-required') { + this.store.commit(MutationTypes.SET_LOGGED_IN, false); + this.store.commit(MutationTypes.SET_COMPONENTS, {login: true}); + } + + if (response.error) { throw new Error(response.error); } @@ -807,6 +810,106 @@ export default class DynmapMapProvider extends MapProvider { return `${this.config.dynmap!.markers}_markers_/${icon}.png`; } + async login(data: any) { + const store = useStore(); + + if (!store.state.components.login) { + return Promise.reject(store.state.messages.loginErrorDisabled); + } + + store.commit(MutationTypes.SET_LOGGED_IN, false); + + try { + const body = new URLSearchParams(); + + body.append('j_username', data.username || ''); + body.append('j_password', data.password || ''); + + + const response = await DynmapMapProvider.fetchJSON(this.config.dynmap!.login, { + method: 'POST', + body, + }); + + switch(response.result) { + case 'success': + store.commit(MutationTypes.SET_LOGGED_IN, true); + return; + + case 'loginfailed': + return Promise.reject(store.state.messages.loginErrorIncorrect); + + default: + return Promise.reject(store.state.messages.loginErrorUnknown); + } + } catch(e) { + console.error(store.state.messages.loginErrorUnknown); + console.trace(e); + return Promise.reject(store.state.messages.loginErrorUnknown); + } + } + + async logout() { + const store = useStore(); + + if (!store.state.components.login) { + return Promise.reject(store.state.messages.loginErrorDisabled); + } + + try { + await DynmapMapProvider.fetchJSON(this.config.dynmap!.login, { + method: 'POST', + }); + + store.commit(MutationTypes.SET_LOGGED_IN, false); + } catch(e) { + return Promise.reject(store.state.messages.logoutErrorUnknown); + } + } + + async register(data: any) { + const store = useStore(); + + if (!store.state.components.login) { + return Promise.reject(store.state.messages.loginErrorDisabled); + } + + store.commit(MutationTypes.SET_LOGGED_IN, false); + + try { + const body = new URLSearchParams(); + + body.append('j_username', data.username || ''); + body.append('j_password', data.password || ''); + body.append('j_verify_password', data.password || ''); + body.append('j_passcode', data.code || ''); + + const response = await DynmapMapProvider.fetchJSON(this.config.dynmap!.register, { + method: 'POST', + body, + }); + + switch(response.result) { + case 'success': + store.commit(MutationTypes.SET_LOGGED_IN, true); + return; + + case 'verifyfailed': + return Promise.reject(store.state.messages.registerErrorVerifyFailed); + + case 'registerfailed': + return Promise.reject(store.state.messages.registerErrorIncorrect); + + default: + return Promise.reject(store.state.messages.registerErrorUnknown); + } + } catch(e) { + console.error(store.state.messages.registerErrorUnknown); + console.trace(e); + return Promise.reject(store.state.messages.registerErrorUnknown); + } + } + destroy() { super.destroy(); diff --git a/src/providers/MapProvider.ts b/src/providers/MapProvider.ts index 63709b8..cd48aa2 100644 --- a/src/providers/MapProvider.ts +++ b/src/providers/MapProvider.ts @@ -54,6 +54,18 @@ export default abstract class MapProvider implements LiveAtlasMapProvider { throw new Error('Provider does not support chat'); } + async login(data: any) { + throw new Error('Provider does not support logging in'); + } + + async logout() { + throw new Error('Provider does not support logging out'); + } + + async register(data: any) { + throw new Error('Provider does not support registration'); + } + destroy() { this.currentWorldUnwatch(); } diff --git a/src/scss/leaflet/_controls.scss b/src/scss/leaflet/_controls.scss index df08f03..ab7cccc 100644 --- a/src/scss/leaflet/_controls.scss +++ b/src/scss/leaflet/_controls.scss @@ -22,6 +22,7 @@ box-sizing: border-box; overflow: visible; font-size: 1.5rem; + flex-shrink: 0; a, button { @extend %button; diff --git a/src/scss/style.scss b/src/scss/style.scss index 5832108..9ed3449 100644 --- a/src/scss/style.scss +++ b/src/scss/style.scss @@ -343,6 +343,33 @@ img { } } +.form { + .form__group { + margin-bottom: 1.5rem; + display: flex; + flex-direction: column; + } + + .form__label { + font-size: 1.6rem; + margin-bottom: 0.5rem; + color: var(--text-emphasis); + } + + &.form--invalid input:invalid { + border-color: var(--background-error); + outline-color: var(--background-error); + } +} + +.alert { + display: flex; + flex-direction: column; + padding: 1rem; + background-color: var(--background-error); + border-radius: var(--border-radius); +} + @media print { @page { size: 297mm 210mm; diff --git a/src/store/action-types.ts b/src/store/action-types.ts index b3a821f..ed8b7ce 100644 --- a/src/store/action-types.ts +++ b/src/store/action-types.ts @@ -25,4 +25,7 @@ export enum ActionTypes { POP_LINE_UPDATES = "popLineUpdates", POP_TILE_UPDATES = "popTileUpdates", SEND_CHAT_MESSAGE = "sendChatMessage", + LOGIN = "login", + LOGOUT = "logout", + REGISTER = "register", } diff --git a/src/store/actions.ts b/src/store/actions.ts index fd93c46..f7a9f88 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -71,6 +71,17 @@ export interface Actions { {commit}: AugmentedActionContext, payload: string ): Promise + [ActionTypes.LOGIN]( + {commit}: AugmentedActionContext, + payload: any + ): Promise + [ActionTypes.LOGOUT]( + {commit}: AugmentedActionContext + ): Promise + [ActionTypes.REGISTER]( + {commit}: AugmentedActionContext, + payload: any + ): Promise } export const actions: ActionTree & Actions = { @@ -240,4 +251,16 @@ export const actions: ActionTree & Actions = { async [ActionTypes.SEND_CHAT_MESSAGE]({commit, state}, message: string): Promise { await state.currentMapProvider!.sendChatMessage(message); }, + + async [ActionTypes.LOGIN]({state, commit}, data: any): Promise { + await state.currentMapProvider!.login(data); + }, + + async [ActionTypes.LOGOUT]({state}): Promise { + await state.currentMapProvider!.logout(); + }, + + async [ActionTypes.REGISTER]({state}, data: any): Promise { + await state.currentMapProvider!.register(data); + }, } diff --git a/src/store/mutation-types.ts b/src/store/mutation-types.ts index 68b2a39..c626991 100644 --- a/src/store/mutation-types.ts +++ b/src/store/mutation-types.ts @@ -55,6 +55,8 @@ export enum MutationTypes { SET_SMALL_SCREEN = 'setSmallScreen', TOGGLE_UI_ELEMENT_VISIBILITY = 'toggleUIElementVisibility', SET_UI_ELEMENT_VISIBILITY = 'setUIElementVisibility', + SHOW_UI_MODAL = 'showUIModal', + HIDE_UI_MODAL = 'hideUIModal', TOGGLE_SIDEBAR_SECTION_COLLAPSED_STATE = 'toggleSidebarSectionCollapsedState', SET_SIDEBAR_SECTION_COLLAPSED_STATE = 'setSidebarSectionCollapsedState', diff --git a/src/store/mutations.ts b/src/store/mutations.ts index 81d05a9..2e116b7 100644 --- a/src/store/mutations.ts +++ b/src/store/mutations.ts @@ -39,7 +39,7 @@ import { LiveAtlasMarker, LiveAtlasMarkerSet, LiveAtlasServerDefinition, - LiveAtlasServerConfig, LiveAtlasChat, LiveAtlasPartialComponentConfig, LiveAtlasComponentConfig + LiveAtlasServerConfig, LiveAtlasChat, LiveAtlasPartialComponentConfig, LiveAtlasComponentConfig, LiveAtlasUIModal } from "@/index"; import DynmapMapProvider from "@/providers/DynmapMapProvider"; import Pl3xmapMapProvider from "@/providers/Pl3xmapMapProvider"; @@ -88,6 +88,8 @@ export type Mutations = { [MutationTypes.SET_SMALL_SCREEN](state: S, payload: boolean): void [MutationTypes.TOGGLE_UI_ELEMENT_VISIBILITY](state: S, payload: LiveAtlasUIElement): void [MutationTypes.SET_UI_ELEMENT_VISIBILITY](state: S, payload: {element: LiveAtlasUIElement, state: boolean}): void + [MutationTypes.SHOW_UI_MODAL](state: S, payload: LiveAtlasUIModal): void + [MutationTypes.HIDE_UI_MODAL](state: S, payload: LiveAtlasUIModal): void [MutationTypes.TOGGLE_SIDEBAR_SECTION_COLLAPSED_STATE](state: S, section: LiveAtlasSidebarSection): void [MutationTypes.SET_SIDEBAR_SECTION_COLLAPSED_STATE](state: S, payload: {section: LiveAtlasSidebarSection, state: boolean}): void @@ -138,6 +140,27 @@ export const mutations: MutationTree & Mutations = { layersTitle: messageConfig.layersTitle || '', copyToClipboardSuccess: messageConfig.copyToClipboardSuccess || '', copyToClipboardError: messageConfig.copyToClipboardError || '', + loginTitle: messageConfig.loginTitle || '', + loginHeading: messageConfig.loginHeading || '', + loginUsernameLabel: messageConfig.loginUsernameLabel || '', + loginPasswordLabel: messageConfig.loginPasswordLabel || '', + loginSubmit: messageConfig.loginSubmit || '', + loginErrorUnknown: messageConfig.loginErrorUnknown || '', + loginErrorDisabled: messageConfig.loginErrorDisabled || '', + loginErrorIncorrect: messageConfig.loginErrorIncorrect || '', + loginSuccess: messageConfig.loginSuccess || '', + registerHeading: messageConfig.registerHeading || '', + registerDescription: messageConfig.registerDescription || '', + registerConfirmPasswordLabel: messageConfig.registerConfirmPasswordLabel || '', + registerCodeLabel: messageConfig.registerCodeLabel || '', + registerSubmit: messageConfig.registerSubmit || '', + registerErrorUnknown: messageConfig.registerErrorUnknown || '', + registerErrorDisabled: messageConfig.registerErrorDisabled || '', + registerErrorVerifyFailed: messageConfig.registerErrorVerifyFailed || '', + registerErrorIncorrect: messageConfig.registerErrorIncorrect || '', + logoutTitle: messageConfig.logoutTitle || '', + logoutErrorUnknown: messageConfig.logoutErrorUnknown || '', + logoutSuccess: messageConfig.logoutSuccess || '', } state.messages = Object.assign(state.messages, messages); @@ -586,6 +609,16 @@ export const mutations: MutationTree & Mutations = { payload.state ? state.ui.visibleElements.add(payload.element) : state.ui.visibleElements.delete(payload.element); }, + [MutationTypes.SHOW_UI_MODAL](state: State, modal: LiveAtlasUIModal): void { + state.ui.visibleModal = modal; + }, + + [MutationTypes.HIDE_UI_MODAL](state: State, modal: LiveAtlasUIModal): void { + if(state.ui.visibleModal === modal) { + state.ui.visibleModal = undefined; + } + }, + [MutationTypes.TOGGLE_SIDEBAR_SECTION_COLLAPSED_STATE](state: State, section: LiveAtlasSidebarSection): void { if(state.ui.sidebar.collapsedSections.has(section)) { state.ui.sidebar.collapsedSections.delete(section); @@ -642,5 +675,7 @@ export const mutations: MutationTree & Mutations = { state.components.chatBox = undefined; state.components.chatBalloons = false; state.components.login = false; + + state.ui.visibleModal = undefined; } } diff --git a/src/store/state.ts b/src/store/state.ts index 102ad1d..2d52f8b 100644 --- a/src/store/state.ts +++ b/src/store/state.ts @@ -32,7 +32,7 @@ import { LiveAtlasPlayer, LiveAtlasMarkerSet, LiveAtlasComponentConfig, - LiveAtlasServerConfig, LiveAtlasChat + LiveAtlasServerConfig, LiveAtlasChat, LiveAtlasUIModal } from "@/index"; import LiveAtlasMapDefinition from "@/model/LiveAtlasMapDefinition"; @@ -78,6 +78,7 @@ export type State = { smallScreen: boolean; visibleElements: Set; + visibleModal?: LiveAtlasUIModal; previouslyVisibleElements: Set; sidebar: { @@ -144,6 +145,27 @@ export const state: State = { layersTitle: '', copyToClipboardSuccess: '', copyToClipboardError: '', + loginTitle: '', + loginHeading: '', + loginUsernameLabel: '', + loginPasswordLabel: '', + loginSubmit: '', + loginErrorUnknown: '', + loginErrorDisabled: '', + loginErrorIncorrect: '', + loginSuccess: '', + registerHeading: '', + registerDescription: '', + registerConfirmPasswordLabel: '', + registerCodeLabel: '', + registerSubmit: '', + registerErrorUnknown: '', + registerErrorDisabled: '', + registerErrorVerifyFailed: '', + registerErrorIncorrect: '', + logoutTitle: '', + logoutErrorUnknown: '', + logoutSuccess: '', }, loggedIn: false, @@ -227,6 +249,7 @@ export const state: State = { smallScreen: false, visibleElements: new Set(), + visibleModal: undefined, previouslyVisibleElements: new Set(), sidebar: {