First attempt at login/register

This commit is contained in:
James Lyne 2021-08-30 22:27:41 +01:00
parent 6028779b45
commit 76f151a64c
21 changed files with 919 additions and 8 deletions

View File

@ -115,6 +115,32 @@
layersTitle: 'Layers', layersTitle: 'Layers',
copyToClipboardSuccess: 'Copied to clipboard', copyToClipboardSuccess: 'Copied to clipboard',
copyToClipboardError: 'Unable to copy 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: { ui: {

View File

@ -17,6 +17,7 @@
<template> <template>
<Map></Map> <Map></Map>
<ChatBox v-if="chatBoxEnabled" v-show="chatBoxEnabled && chatVisible"></ChatBox> <ChatBox v-if="chatBoxEnabled" v-show="chatBoxEnabled && chatVisible"></ChatBox>
<LoginModal v-if="loginEnabled"></LoginModal>
<Sidebar></Sidebar> <Sidebar></Sidebar>
<notifications position="bottom center" :speed="250" :max="3" :ignoreDuplicates="true" classes="notification" /> <notifications position="bottom center" :speed="250" :max="3" :ignoreDuplicates="true" classes="notification" />
</template> </template>
@ -32,13 +33,16 @@ import {parseUrl} from '@/util';
import {hideSplash, showSplash, showSplashError} from '@/util/splash'; import {hideSplash, showSplash, showSplashError} from '@/util/splash';
import {MutationTypes} from "@/store/mutation-types"; import {MutationTypes} from "@/store/mutation-types";
import {LiveAtlasServerDefinition, LiveAtlasUIElement} from "@/index"; import {LiveAtlasServerDefinition, LiveAtlasUIElement} from "@/index";
import LoginModal from "@/components/login/LoginModal.vue";
import {notify} from "@kyvg/vue3-notification";
export default defineComponent({ export default defineComponent({
name: 'App', name: 'App',
components: { components: {
Map, Map,
Sidebar, Sidebar,
ChatBox ChatBox,
LoginModal
}, },
setup() { setup() {
@ -48,6 +52,7 @@ export default defineComponent({
currentServer = computed(() => store.state.currentServer), currentServer = computed(() => store.state.currentServer),
configurationHash = computed(() => store.state.configurationHash), configurationHash = computed(() => store.state.configurationHash),
chatBoxEnabled = computed(() => store.state.components.chatBox), chatBoxEnabled = computed(() => store.state.components.chatBox),
loginEnabled = computed(() => store.state.components.login),
chatVisible = computed(() => store.state.ui.visibleElements.has('chat')), chatVisible = computed(() => store.state.ui.visibleElements.has('chat')),
loggedIn = computed(() => store.state.loggedIn), loggedIn = computed(() => store.state.loggedIn),
@ -79,6 +84,14 @@ export default defineComponent({
return; 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}'`; const error = `Failed to load server configuration for '${store.state.currentServer!.id}'`;
console.error(`${error}:`, e); console.error(`${error}:`, e);
showSplashError(`${error}\n${e}`, false, ++loadingAttempts.value); showSplashError(`${error}\n${e}`, false, ++loadingAttempts.value);
@ -163,6 +176,12 @@ export default defineComponent({
await loadConfiguration(); await loadConfiguration();
} }
}); });
watch(loggedIn, async () => {
if(!loading.value) {
console.log('Login state changed. Reloading configuration');
await loadConfiguration();
}
})
onMounted(() => loadConfiguration()); onMounted(() => loadConfiguration());
onBeforeUnmount(() => store.dispatch(ActionTypes.STOP_UPDATES, undefined)); onBeforeUnmount(() => store.dispatch(ActionTypes.STOP_UPDATES, undefined));
@ -184,6 +203,7 @@ export default defineComponent({
return { return {
chatBoxEnabled, chatBoxEnabled,
chatVisible, chatVisible,
loginEnabled,
} }
}, },
}); });

View File

@ -0,0 +1 @@
<svg width="375.12" height="369.507" version="1.0" viewBox="0 0 281.34 277.13" xmlns="http://www.w3.org/2000/svg"><g stroke-linecap="round"><path d="M117.345 0c-15.437 0-28.178 12.74-28.178 28.176v87.008h20V28.176c0-4.702 3.476-8.175 8.178-8.175h135.817c4.703 0 8.177 3.473 8.177 8.175v220.778c0 4.702-3.474 8.176-8.176 8.176H117.344c-4.702 0-8.178-3.474-8.178-8.176V167.24h-20v81.712c0 15.437 12.742 28.178 28.178 28.178h135.817c15.437 0 28.176-12.741 28.176-28.178V28.176C281.339 12.74 268.6 0 253.163 0H117.345z"/><path d="M148.458 77.933a12.5 12.5 0 0 0-8.848 3.643 12.5 12.5 0 0 0-.04 17.678l29.329 29.457H12.508a12.5 12.5 0 0 0-12.5 12.501 12.5 12.5 0 0 0 12.5 12.5h156.39l-29.328 29.457a12.5 12.5 0 0 0 .04 17.678 12.5 12.5 0 0 0 17.677-.038l50.552-50.777a12.501 12.501 0 0 0 0-17.64l-50.551-50.774a12.5 12.5 0 0 0-8.83-3.683z"/></g></svg>

After

Width:  |  Height:  |  Size: 846 B

View File

@ -0,0 +1 @@
<svg width="375.12" height="369.507" version="1.0" viewBox="0 0 281.34 277.13" xmlns="http://www.w3.org/2000/svg"><g stroke-linecap="round"><path d="M117.345 0c-15.437 0-28.178 12.74-28.178 28.176v87.008h20V28.176c0-4.702 3.476-8.175 8.178-8.175h135.817c4.703 0 8.177 3.473 8.177 8.175v220.778c0 4.702-3.474 8.176-8.176 8.176H117.344c-4.702 0-8.178-3.474-8.178-8.176V167.24h-20v81.712c0 15.437 12.742 28.178 28.178 28.178h135.817c15.437 0 28.176-12.741 28.176-28.178V28.176C281.339 12.74 268.6 0 253.163 0z"/><path d="M63.024 77.933a12.5 12.5 0 0 1 8.848 3.643 12.5 12.5 0 0 1 .04 17.678l-29.329 29.457h156.39a12.5 12.5 0 0 1 12.5 12.501 12.5 12.5 0 0 1-12.5 12.5H42.583l29.328 29.457a12.5 12.5 0 0 1-.04 17.678 12.5 12.5 0 0 1-17.677-.038L3.642 150.032a12.501 12.501 0 0 1 0-17.64l50.552-50.774a12.5 12.5 0 0 1 8.83-3.683z"/></g></svg>

After

Width:  |  Height:  |  Size: 836 B

View File

@ -25,9 +25,12 @@
<CoordinatesControl v-if="coordinatesControlEnabled" :leaflet="leaflet"></CoordinatesControl> <CoordinatesControl v-if="coordinatesControlEnabled" :leaflet="leaflet"></CoordinatesControl>
<LinkControl v-if="linkControlEnabled" :leaflet="leaflet"></LinkControl> <LinkControl v-if="linkControlEnabled" :leaflet="leaflet"></LinkControl>
<ClockControl v-if="clockControlEnabled" :leaflet="leaflet"></ClockControl> <ClockControl v-if="clockControlEnabled" :leaflet="leaflet"></ClockControl>
<LoginControl v-if="loginEnabled" :leaflet="leaflet"></LoginControl>
<ChatControl v-if="chatBoxEnabled" :leaflet="leaflet"></ChatControl> <ChatControl v-if="chatBoxEnabled" :leaflet="leaflet"></ChatControl>
</template> </template>
</div> </div>
<MapContextMenu :leaflet="leaflet" v-if="leaflet"></MapContextMenu> <MapContextMenu :leaflet="leaflet" v-if="leaflet"></MapContextMenu>
</template> </template>
@ -48,6 +51,7 @@ import LiveAtlasLeafletMap from "@/leaflet/LiveAtlasLeafletMap";
import {LoadingControl} from "@/leaflet/control/LoadingControl"; import {LoadingControl} from "@/leaflet/control/LoadingControl";
import MapContextMenu from "@/components/map/MapContextMenu.vue"; import MapContextMenu from "@/components/map/MapContextMenu.vue";
import {Coordinate, LiveAtlasPlayer} from "@/index"; import {Coordinate, LiveAtlasPlayer} from "@/index";
import LoginControl from "@/components/map/control/LoginControl.vue";
export default defineComponent({ export default defineComponent({
components: { components: {
@ -59,7 +63,8 @@ export default defineComponent({
ClockControl, ClockControl,
LinkControl, LinkControl,
ChatControl, ChatControl,
LogoControl LogoControl,
LoginControl
}, },
setup() { setup() {
@ -75,6 +80,7 @@ export default defineComponent({
clockControlEnabled = computed(() => store.getters.clockControlEnabled), clockControlEnabled = computed(() => store.getters.clockControlEnabled),
linkControlEnabled = computed(() => store.state.components.linkControl), linkControlEnabled = computed(() => store.state.components.linkControl),
chatBoxEnabled = computed(() => store.state.components.chatBox), chatBoxEnabled = computed(() => store.state.components.chatBox),
loginEnabled = computed(() => store.state.components.login),
logoControls = computed(() => store.state.components.logoControls), logoControls = computed(() => store.state.components.logoControls),
currentWorld = computed(() => store.state.currentWorld), currentWorld = computed(() => store.state.currentWorld),
@ -102,6 +108,7 @@ export default defineComponent({
clockControlEnabled, clockControlEnabled,
linkControlEnabled, linkControlEnabled,
chatBoxEnabled, chatBoxEnabled,
loginEnabled,
logoControls, logoControls,
followTarget, followTarget,

101
src/components/Modal.vue Normal file
View File

@ -0,0 +1,101 @@
<!--
- 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.
-->
<template>
<div :class="{'modal': true, 'modal--visible': visible}" role="dialog" :id="`modal--${id}`"
:aria-labelledby="`${id}__heading`" aria-modal="true" @click="onClick" ref="modal">
<div class="modal__content">
<slot></slot>
</div>
</div>
</template>
<script lang="ts">
import {defineComponent, onMounted, onUnmounted} from "@vue/runtime-core";
import {useStore} from "@/store";
import {MutationTypes} from "@/store/mutation-types";
import {LiveAtlasUIModal} from "@/index";
import {computed, ref} from "vue";
export default defineComponent({
props: {
id: {
required: true,
type: String,
}
},
setup(props) {
const store = useStore(),
modal = ref<HTMLElement | null>(null),
visible = computed(() => store.state.ui.visibleModal === props.id);
const onKeydown = (e: KeyboardEvent) => {
if(visible.value && e.key === 'Escape') {
store.commit(MutationTypes.HIDE_UI_MODAL, props.id as LiveAtlasUIModal);
e.preventDefault();
}
};
const onClick = (e: MouseEvent) => {
if(e.target === modal.value) {
store.commit(MutationTypes.HIDE_UI_MODAL, props.id as LiveAtlasUIModal);
}
};
onMounted(() => {
window.addEventListener('keydown', onKeydown);
});
onUnmounted(() => {
window.addEventListener('keydown', onKeydown);
});
return {
visible,
modal,
onClick,
}
}
});
</script>
<style lang="scss">
@import '../scss/placeholders';
.modal {
position: fixed;
z-index: 120;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: none;
align-items: flex-start;
justify-content: center;
padding: 10vh 1rem;
background-color: rgba(0, 0, 0, 0.8);
overflow: auto;
&.modal--visible {
display: flex;
}
.modal__content {
@extend %panel;
max-width: 80rem;
width: 100%;
}
}
</style>

View File

@ -0,0 +1,125 @@
<!--
- 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.
-->
<template>
<form :class="{'form': true, 'form--invalid': invalid}" @submit.prevent="login" ref="form" novalidate>
<h3>{{ loginHeading }}</h3>
<div class="form__group">
<label for="login-username" class="form__label" >{{ usernameLabel }}</label>
<input id="login-username" type="text" name="username" autocomplete="username"
v-model="loginUsername" required ref="usernameField" />
</div>
<div class="form__group">
<label for="login-password" class="form__label" >{{ passwordLabel }}</label>
<input id="login-password" type="password" name="password" autocomplete="current-password"
v-model="loginPassword" required />
</div>
<div role="alert" v-if="error" class="form__group alert">{{ error }}</div>
<div class="form__group">
<button type="submit" :disabled="submitting">{{ loginSubmit }}</button>
</div>
</form>
</template>
<script lang="ts">
import {defineComponent} from "@vue/runtime-core";
import {useStore} from "@/store";
import {computed, ref, watch} from "vue";
import {ActionTypes} from "@/store/action-types";
import {MutationTypes} from "@/store/mutation-types";
import {notify} from "@kyvg/vue3-notification";
export default defineComponent({
setup() {
const store = useStore(),
form = ref<HTMLFormElement | null>(null),
usernameField = ref<HTMLFormElement | null>(null),
loginModalVisible = computed(() => store.state.ui.visibleModal === 'login'),
heading = computed(() => store.state.messages.loginHeading),
loginHeading = computed(() => store.state.messages.loginHeading),
usernameLabel = computed(() => store.state.messages.loginUsernameLabel),
passwordLabel = computed(() => store.state.messages.loginPasswordLabel),
loginSubmit = computed(() => store.state.messages.loginSubmit),
loginSuccess = computed(() => store.state.messages.loginSuccess),
loginUsername = ref(''),
loginPassword = ref(''),
submitting = ref(false),
invalid = ref(false),
error = ref(null);
watch(loginModalVisible, (newValue) => {
if(newValue) {
requestAnimationFrame(() => usernameField.value!.focus());
} else {
loginUsername.value = '';
loginPassword.value = '';
}
});
const login = async () => {
error.value = null;
invalid.value = !form.value!.reportValidity();
if(invalid.value) {
return;
}
try {
submitting.value = true;
await store.dispatch(ActionTypes.LOGIN, {
username: loginUsername.value,
password: loginPassword.value,
});
store.commit(MutationTypes.HIDE_UI_MODAL, 'login');
notify(loginSuccess.value);
} catch(e: any) {
error.value = e;
} finally {
submitting.value = false;
}
};
return {
form,
usernameField,
heading,
loginHeading,
usernameLabel,
passwordLabel,
loginSubmit,
loginUsername,
loginPassword,
submitting,
invalid,
error,
login,
};
}
});
</script>

View File

@ -0,0 +1,70 @@
<!--
- 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.
-->
<template>
<Modal id="login">
<h2 id="login__heading">{{ heading }}</h2>
<LoginForm></LoginForm>
<RegisterForm></RegisterForm>
</Modal>
</template>
<script lang="ts">
import {defineComponent} from "@vue/runtime-core";
import {useStore} from "@/store";
import {computed} from "vue";
import LoginForm from "@/components/login/LoginForm.vue";
import RegisterForm from "@/components/login/RegisterForm.vue";
import Modal from "@/components/Modal.vue";
export default defineComponent({
components: {Modal, RegisterForm, LoginForm},
setup() {
const store = useStore(),
heading = computed(() => store.state.messages.loginTitle);
return {
heading
};
}
});
</script>
<style lang="scss" scoped>
#modal--login {
::v-deep(.modal__content) {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
}
#login__heading,
#login__error {
width: 100%;
text-align: center;
margin-bottom: 2rem;
}
.form {
width: 50%;
padding: 1rem;
box-sizing: border-box;
}
}
</style>

View File

@ -0,0 +1,163 @@
<!--
- 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.
-->
<template>
<form :class="{'form': true, 'form--invalid': invalid}" @submit.prevent="register" ref="form" novalidate>
<h3>{{ messageHeading }}</h3>
<p>{{ messageDescription }}</p>
<div class="form__group">
<label for="register-username" class="form__label" >{{ messageUsernameLabel }}</label>
<input id="register-username" type="text" name="username" autocomplete="username"
v-model="valueUsername" required />
</div>
<div class="form__group">
<label for="register-password" class="form__label" >{{ messagePasswordLabel }}</label>
<input id="register-password" type="password" name="password" autocomplete="new-password"
v-model="valuePassword" required />
</div>
<div class="form__group">
<label for="register-confirm-password" class="form__label">{{ messageConfirmPasswordLabel }}</label>
<input id="register-confirm-password" type="password" name="confirm_password"
autocomplete="new-password" v-model="valuePassword2" required ref="confirmPasswordField"
/>
</div>
<div class="form__group">
<label for="register-code" class="form__label">{{ messageRegisterCodeLabel }}</label>
<input id="register-code" type="text" name="code" v-model="valueCode" required />
</div>
<div role="alert" v-if="error" class="form__group alert">{{ error }}</div>
<div class="form__group">
<button type="submit" :disabled="submitting">{{ messageSubmit }}</button>
</div>
</form>
</template>
<script lang="ts">
import {defineComponent, watch} from "@vue/runtime-core";
import {useStore} from "@/store";
import {computed, ref} from "vue";
import {ActionTypes} from "@/store/action-types";
import {MutationTypes} from "@/store/mutation-types";
export default defineComponent({
setup() {
const store = useStore(),
form = ref<HTMLFormElement | null>(null),
confirmPasswordField = ref<HTMLInputElement | null>(null),
loginModalVisible = computed(() => store.state.ui.visibleModal === 'login'),
messageUsernameLabel = computed(() => store.state.messages.loginUsernameLabel),
messagePasswordLabel = computed(() => store.state.messages.loginPasswordLabel),
messageConfirmPasswordLabel = computed(() => store.state.messages.registerConfirmPasswordLabel),
messageRegisterCodeLabel = computed(() => store.state.messages.registerCodeLabel),
messageHeading = computed(() => store.state.messages.registerHeading),
messageDescription = computed(() => store.state.messages.registerDescription),
messageSubmit = computed(() => store.state.messages.registerSubmit),
messagePasswordMismatch = computed(() => store.state.messages.registerErrorVerifyFailed),
valueUsername = ref(''),
valuePassword = ref(''),
valuePassword2 = ref(''),
valueCode = ref(''),
submitting = ref(false),
invalid = ref(false),
error = ref(null);
watch(loginModalVisible, (newValue) => {
if(!newValue) {
valueUsername.value = '';
valuePassword.value = '';
valuePassword2.value = '';
valueCode.value = '';
}
});
const checkPasswords = () => {
if(valuePassword.value !== valuePassword2.value) {
console.log(messagePasswordMismatch.value);
confirmPasswordField.value!.setCustomValidity(messagePasswordMismatch.value)
} else {
confirmPasswordField.value!.setCustomValidity('');
}
}
const register = async () => {
error.value = null;
checkPasswords();
invalid.value = !form.value!.reportValidity();
if(invalid.value) {
return;
}
try {
submitting.value = true;
await store.dispatch(ActionTypes.REGISTER, {
username: valueUsername.value,
password: valuePassword.value,
code: valueCode.value,
});
store.commit(MutationTypes.HIDE_UI_MODAL, 'login');
} catch(e: any) {
error.value = e;
} finally {
submitting.value = false;
}
}
return {
form,
confirmPasswordField,
messageHeading,
messageDescription,
messageUsernameLabel,
messagePasswordLabel,
messageConfirmPasswordLabel,
messageRegisterCodeLabel,
messageSubmit,
valueUsername,
valuePassword,
valuePassword2,
valueCode,
submitting,
invalid,
error,
register
};
}
});
</script>
<style lang="scss" scoped>
p {
white-space: pre-line;
}
</style>

View File

@ -0,0 +1,43 @@
<!--
- 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.
-->
<script lang="ts">
import {defineComponent, onMounted, onUnmounted} from "@vue/runtime-core";
import LiveAtlasLeafletMap from "@/leaflet/LiveAtlasLeafletMap";
import {LoginControl} from "@/leaflet/control/LoginControl";
export default defineComponent({
props: {
leaflet: {
type: Object as () => LiveAtlasLeafletMap,
required: true,
}
},
setup(props) {
const control = new LoginControl({
position: 'topleft',
});
onMounted(() => props.leaflet.addControl(control));
onUnmounted(() => props.leaflet.removeControl(control));
},
render() {
return null;
}
})
</script>

27
src/index.d.ts vendored
View File

@ -100,6 +100,27 @@ interface LiveAtlasGlobalMessageConfig {
layersTitle: string; layersTitle: string;
copyToClipboardSuccess: string; copyToClipboardSuccess: string;
copyToClipboardError: 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 // Messages defined by dynmap configuration responses and can vary per server
@ -122,7 +143,8 @@ interface LiveAtlasUIConfig {
playersSearch: boolean; 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 LiveAtlasSidebarSection = 'servers' | 'players' | 'maps';
export type LiveAtlasDimension = 'overworld' | 'nether' | 'end'; export type LiveAtlasDimension = 'overworld' | 'nether' | 'end';
@ -172,6 +194,9 @@ interface LiveAtlasMapProvider {
startUpdates(): void; startUpdates(): void;
stopUpdates(): void; stopUpdates(): void;
sendChatMessage(message: string): void; sendChatMessage(message: string): void;
login(formData: FormData): void;
logout(): void;
register(formData: FormData): void;
destroy(): void; destroy(): void;
getPlayerHeadUrl(entry: HeadQueueEntry): string; getPlayerHeadUrl(entry: HeadQueueEntry): string;

View File

@ -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 = `
<svg class="svg-icon">
<use xlink:href="#icon--${this.loggedIn.value ? 'logout' : 'login'}" />
</svg>`;
}
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');
}
}
}

View File

@ -629,8 +629,11 @@ export default class DynmapMapProvider extends MapProvider {
const response = await DynmapMapProvider.getJSON(this.config.dynmap!.configuration, this.configurationAbort.signal); const response = await DynmapMapProvider.getJSON(this.config.dynmap!.configuration, this.configurationAbort.signal);
if(response.error === 'login-required') { if(response.error === 'login-required') {
throw new Error("Login required"); this.store.commit(MutationTypes.SET_LOGGED_IN, false);
} else if (response.error) { this.store.commit(MutationTypes.SET_COMPONENTS, {login: true});
}
if (response.error) {
throw new Error(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`; 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() { destroy() {
super.destroy(); super.destroy();

View File

@ -54,6 +54,18 @@ export default abstract class MapProvider implements LiveAtlasMapProvider {
throw new Error('Provider does not support chat'); 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() { destroy() {
this.currentWorldUnwatch(); this.currentWorldUnwatch();
} }

View File

@ -22,6 +22,7 @@
box-sizing: border-box; box-sizing: border-box;
overflow: visible; overflow: visible;
font-size: 1.5rem; font-size: 1.5rem;
flex-shrink: 0;
a, button { a, button {
@extend %button; @extend %button;

View File

@ -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 { @media print {
@page { @page {
size: 297mm 210mm; size: 297mm 210mm;

View File

@ -25,4 +25,7 @@ export enum ActionTypes {
POP_LINE_UPDATES = "popLineUpdates", POP_LINE_UPDATES = "popLineUpdates",
POP_TILE_UPDATES = "popTileUpdates", POP_TILE_UPDATES = "popTileUpdates",
SEND_CHAT_MESSAGE = "sendChatMessage", SEND_CHAT_MESSAGE = "sendChatMessage",
LOGIN = "login",
LOGOUT = "logout",
REGISTER = "register",
} }

View File

@ -71,6 +71,17 @@ export interface Actions {
{commit}: AugmentedActionContext, {commit}: AugmentedActionContext,
payload: string payload: string
): Promise<void> ): Promise<void>
[ActionTypes.LOGIN](
{commit}: AugmentedActionContext,
payload: any
): Promise<void>
[ActionTypes.LOGOUT](
{commit}: AugmentedActionContext
): Promise<void>
[ActionTypes.REGISTER](
{commit}: AugmentedActionContext,
payload: any
): Promise<void>
} }
export const actions: ActionTree<State, State> & Actions = { export const actions: ActionTree<State, State> & Actions = {
@ -240,4 +251,16 @@ export const actions: ActionTree<State, State> & Actions = {
async [ActionTypes.SEND_CHAT_MESSAGE]({commit, state}, message: string): Promise<void> { async [ActionTypes.SEND_CHAT_MESSAGE]({commit, state}, message: string): Promise<void> {
await state.currentMapProvider!.sendChatMessage(message); await state.currentMapProvider!.sendChatMessage(message);
}, },
async [ActionTypes.LOGIN]({state, commit}, data: any): Promise<void> {
await state.currentMapProvider!.login(data);
},
async [ActionTypes.LOGOUT]({state}): Promise<void> {
await state.currentMapProvider!.logout();
},
async [ActionTypes.REGISTER]({state}, data: any): Promise<void> {
await state.currentMapProvider!.register(data);
},
} }

View File

@ -55,6 +55,8 @@ export enum MutationTypes {
SET_SMALL_SCREEN = 'setSmallScreen', SET_SMALL_SCREEN = 'setSmallScreen',
TOGGLE_UI_ELEMENT_VISIBILITY = 'toggleUIElementVisibility', TOGGLE_UI_ELEMENT_VISIBILITY = 'toggleUIElementVisibility',
SET_UI_ELEMENT_VISIBILITY = 'setUIElementVisibility', SET_UI_ELEMENT_VISIBILITY = 'setUIElementVisibility',
SHOW_UI_MODAL = 'showUIModal',
HIDE_UI_MODAL = 'hideUIModal',
TOGGLE_SIDEBAR_SECTION_COLLAPSED_STATE = 'toggleSidebarSectionCollapsedState', TOGGLE_SIDEBAR_SECTION_COLLAPSED_STATE = 'toggleSidebarSectionCollapsedState',
SET_SIDEBAR_SECTION_COLLAPSED_STATE = 'setSidebarSectionCollapsedState', SET_SIDEBAR_SECTION_COLLAPSED_STATE = 'setSidebarSectionCollapsedState',

View File

@ -39,7 +39,7 @@ import {
LiveAtlasMarker, LiveAtlasMarker,
LiveAtlasMarkerSet, LiveAtlasMarkerSet,
LiveAtlasServerDefinition, LiveAtlasServerDefinition,
LiveAtlasServerConfig, LiveAtlasChat, LiveAtlasPartialComponentConfig, LiveAtlasComponentConfig LiveAtlasServerConfig, LiveAtlasChat, LiveAtlasPartialComponentConfig, LiveAtlasComponentConfig, LiveAtlasUIModal
} from "@/index"; } from "@/index";
import DynmapMapProvider from "@/providers/DynmapMapProvider"; import DynmapMapProvider from "@/providers/DynmapMapProvider";
import Pl3xmapMapProvider from "@/providers/Pl3xmapMapProvider"; import Pl3xmapMapProvider from "@/providers/Pl3xmapMapProvider";
@ -88,6 +88,8 @@ export type Mutations<S = State> = {
[MutationTypes.SET_SMALL_SCREEN](state: S, payload: boolean): void [MutationTypes.SET_SMALL_SCREEN](state: S, payload: boolean): void
[MutationTypes.TOGGLE_UI_ELEMENT_VISIBILITY](state: S, payload: LiveAtlasUIElement): 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.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.TOGGLE_SIDEBAR_SECTION_COLLAPSED_STATE](state: S, section: LiveAtlasSidebarSection): void
[MutationTypes.SET_SIDEBAR_SECTION_COLLAPSED_STATE](state: S, payload: {section: LiveAtlasSidebarSection, state: boolean}): void [MutationTypes.SET_SIDEBAR_SECTION_COLLAPSED_STATE](state: S, payload: {section: LiveAtlasSidebarSection, state: boolean}): void
@ -138,6 +140,27 @@ export const mutations: MutationTree<State> & Mutations = {
layersTitle: messageConfig.layersTitle || '', layersTitle: messageConfig.layersTitle || '',
copyToClipboardSuccess: messageConfig.copyToClipboardSuccess || '', copyToClipboardSuccess: messageConfig.copyToClipboardSuccess || '',
copyToClipboardError: messageConfig.copyToClipboardError || '', 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); state.messages = Object.assign(state.messages, messages);
@ -586,6 +609,16 @@ export const mutations: MutationTree<State> & Mutations = {
payload.state ? state.ui.visibleElements.add(payload.element) : state.ui.visibleElements.delete(payload.element); 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 { [MutationTypes.TOGGLE_SIDEBAR_SECTION_COLLAPSED_STATE](state: State, section: LiveAtlasSidebarSection): void {
if(state.ui.sidebar.collapsedSections.has(section)) { if(state.ui.sidebar.collapsedSections.has(section)) {
state.ui.sidebar.collapsedSections.delete(section); state.ui.sidebar.collapsedSections.delete(section);
@ -642,5 +675,7 @@ export const mutations: MutationTree<State> & Mutations = {
state.components.chatBox = undefined; state.components.chatBox = undefined;
state.components.chatBalloons = false; state.components.chatBalloons = false;
state.components.login = false; state.components.login = false;
state.ui.visibleModal = undefined;
} }
} }

View File

@ -32,7 +32,7 @@ import {
LiveAtlasPlayer, LiveAtlasPlayer,
LiveAtlasMarkerSet, LiveAtlasMarkerSet,
LiveAtlasComponentConfig, LiveAtlasComponentConfig,
LiveAtlasServerConfig, LiveAtlasChat LiveAtlasServerConfig, LiveAtlasChat, LiveAtlasUIModal
} from "@/index"; } from "@/index";
import LiveAtlasMapDefinition from "@/model/LiveAtlasMapDefinition"; import LiveAtlasMapDefinition from "@/model/LiveAtlasMapDefinition";
@ -78,6 +78,7 @@ export type State = {
smallScreen: boolean; smallScreen: boolean;
visibleElements: Set<LiveAtlasUIElement>; visibleElements: Set<LiveAtlasUIElement>;
visibleModal?: LiveAtlasUIModal;
previouslyVisibleElements: Set<LiveAtlasUIElement>; previouslyVisibleElements: Set<LiveAtlasUIElement>;
sidebar: { sidebar: {
@ -144,6 +145,27 @@ export const state: State = {
layersTitle: '', layersTitle: '',
copyToClipboardSuccess: '', copyToClipboardSuccess: '',
copyToClipboardError: '', copyToClipboardError: '',
loginTitle: '',
loginHeading: '',
loginUsernameLabel: '',
loginPasswordLabel: '',
loginSubmit: '',
loginErrorUnknown: '',
loginErrorDisabled: '',
loginErrorIncorrect: '',
loginSuccess: '',
registerHeading: '',
registerDescription: '',
registerConfirmPasswordLabel: '',
registerCodeLabel: '',
registerSubmit: '',
registerErrorUnknown: '',
registerErrorDisabled: '',
registerErrorVerifyFailed: '',
registerErrorIncorrect: '',
logoutTitle: '',
logoutErrorUnknown: '',
logoutSuccess: '',
}, },
loggedIn: false, loggedIn: false,
@ -227,6 +249,7 @@ export const state: State = {
smallScreen: false, smallScreen: false,
visibleElements: new Set(), visibleElements: new Set(),
visibleModal: undefined,
previouslyVisibleElements: new Set(), previouslyVisibleElements: new Set(),
sidebar: { sidebar: {