<template>
<main class="registration">
<h1>Account creation</h1>
<p>Welcome to The Mana World! With this form you can register for a new game account.</p>
<p>Please note that you will also need to down and install ManaPlus, our official game client.</p>
<br>
<button v-if="!step" @click="start">Begin!</button>
<div v-if="step == -1">
<h1>reCAPTCHA could not be loaded</h1>
<p>This page requires reCAPTCHA but something prevents it from loading.
If you are using an ad blocker or tracker blocker please whitelist this page and refresh to continue.</p>
<h1>Registering without reCAPTCHA</h1>
<p>If you would rather not use reCAPTCHA, you may register by contacting us by email.</p>
<p>We will create a new account for you and associate it with the email address that you used to contact us.</p>
<br>
<address>sysadmin@themanaworld.org</address>
</div>
<!-- XXX: do we want to add the game rules here? -->
<div v-if="step == 1">
<h1>Email address</h1>
<p>We will never give your email address to someone else or send you spam.</p>
<p>Providing an email address is entirely optional but it is the only way to request a password reset, should you loose access to your account.
If you did not provide an email address you will be unable to perform password resets.</p>
<form @submit.prevent="checkEmail">
<label for="email">Enter your email (optional):</label>
<input v-model="user.email" type="email" maxlength="39" id="email" ref="email" placeholder="your@email.com">
<button type="submit">Next step →</button>
</form>
</div>
<div v-if="step == 2">
<h1>Username</h1>
<p>Your username is used to log in to the game server. It is never shared with other players: only you see this name.</p>
<p>It must contain between 4 and 23 characters. Letters and numbers only.</p>
<div class="error taken" v-if="taken">
<h2>Username taken</h2>
<p>Please choose another username.</p>
</div>
<form @submit.prevent="checkUser">
<label for="user">Choose a username:</label>
<input @input="taken = false" v-model="user.name" type="text" id="user" ref="user" placeholder="type your username here" minlength="4" maxlength="23" pattern="^[a-zA-Z0-9]{4,23}$" title="4-23 characters, alphanumeric" required>
<button type="submit" v-if="user.name">Next step →</button>
</form>
</div>
<div v-if="step == 3">
<h1>Password</h1>
<p>Please choose a hard-to-guess password.</p>
<p>It must contain between 8 and 23 characters. Letters and numbers only. Case-sensitive.</p>
<div v-if="exposed" class="exposed">
<h2>WARNING: This password is compromised</h2>
This password has previously appeared in a data breach. Please use a more secure alternative.
<a href="https://haveibeenpwned.com/Passwords" target="_blank" rel="noopener">verified by haveibeenpwned.com</a>
</div>
<form @submit.prevent="checkPassword">
<div class="pass-box">
<label for="password">Choose a unique password:</label>
<input v-model="user.pwd" :type="visible ? 'text' : 'password'" id="password" ref="password" placeholder="type your password here" minlength="8" maxlength="23" pattern="^[a-zA-Z0-9]{8,23}$" title="8-23 characters, alphanumeric" required>
<span @click="visible = !visible"></span>
</div>
<div class="pass-box">
<label for="password2">Confirm your password:</label>
<input v-model="user.pwd2" :type="visible ? 'text' : 'password'" id="password2" ref="password2" placeholder="type your password again" minlength="8" maxlength="23" pattern="^[a-zA-Z0-9]{8,23}$" title="8-23 characters, alphanumeric" required>
<span @click="visible = !visible"></span>
</div>
<button type="submit" v-if="user.pwd && user.pwd === user.pwd2">Next step →</button>
</form>
</div>
<div v-if="step == 4">
<h1>Confirm</h1>
<label for="c-email">Email address:</label>
<input id="c-email" disabled readonly type="email" :value="user.email" placeholder="(no email)">
<label for="c-user">Username:</label>
<input id="c-user" disabled readonly type="text" :value="user.name">
<div class="pass-box">
<label for="c-pass">Password:</label>
<input id="c-pass" disabled readonly :type="visible ? 'text' : 'password'" :value="user.pwd">
<span @click="visible = !visible"></span>
</div>
<button @click="create">Create account</button>
</div>
<div v-if="step == 5">
<h1>Thank you</h1>
<p>Your account has been successfully created.</p>
<h1>Next steps</h1>
<p>To start playing, <a href="https://wiki.themanaworld.org/index.php/Downloads">download ManaPlus</a> and select the server <i>The Mana World</i>.</p>
</div>
<div class="g-recaptcha" id="recaptcha-container"
:data-sitekey="recaptcha_key"
data-size="invisible">
</div>
<script2 src="https://www.google.com/recaptcha/api.js" unload="Reflect.deleteProperty(self, 'grecaptcha')"/>
</main>
</template>
<script lang="ts">
import Vue from "vue"
import Component from "vue-class-component"
@Component({
beforeRouteLeave: (to, from, next) => {
next();
}
})
export default class Registration extends Vue {
step = 0;
visible = false; // password is visible or hidden
exposed = false; // password has been leaked
taken = false; // username is taken
user = {
email: "",
name: "",
pwd: "",
pwd2: "",
};
recaptcha_key = process.env.VUE_APP_RECAPTCHA;
async mounted () {
// already loaded (user returned to this page)
if (Reflect.has(self, "grecaptcha")) {
await this.$nextTick();
(self as any).grecaptcha.render("recaptcha-container", {
sitekey: process.env.VUE_APP_RECAPTCHA,
size: "invisible",
});
(self as any).grecaptcha.reset();
}
}
async start () {
this.step = Reflect.has(self, "grecaptcha") ? 1 : -1;
await this.$nextTick();
(this.$refs.email as any).focus();
}
async checkEmail () {
this.step = 2;
await this.$nextTick();
(this.$refs.user as any).focus();
}
async checkUser () {
// TODO: check here whether the username is taken
this.step = 3;
await this.$nextTick();
(this.$refs.password as any).focus();
}
// TODO: this is not compatible with Edge! we must polyfill
private async sha1 (text: string) {
const encoder = new TextEncoder();
const data = encoder.encode(text);
const buffer = await self.crypto.subtle.digest("SHA-1", data);
return this.hexString(buffer);
}
// turns a subtlecrypto arraybuffer into a usable hex string
private hexString (buffer: ArrayBuffer) {
const byteArray = new Uint8Array(buffer);
const hexCodes = Array.from(byteArray).map(value =>
value.toString(16).padStart(2, "0"));
return hexCodes.join("");
}
async checkPassword () {
const full_hash = await this.sha1(this.user.pwd);
const hash_prefix = full_hash.substring(0, 5);
const hash_suffix = full_hash.substring(5);
const req = new Request(`https://api.pwnedpasswords.com/range/${hash_prefix}`, {
mode: "cors",
cache: "force-cache",
referrer: "no-referrer",
});
const raw_response = await fetch(req);
const response: string = await raw_response.text();
const found = response.split("\n").some(h => {
const [hs, times] = h.split(":");
return hash_suffix.toUpperCase() === hs.toUpperCase();
});
if (found) {
// reset the animation
if (this.exposed) {
this.exposed = false;
await this.$nextTick();
}
this.exposed = true;
await this.$nextTick();
(this.$refs.password as any).focus();
} else {
this.exposed = false;
this.step = 4;
}
}
sleep (milliseconds: number) {
return new Promise(resolve => setTimeout(resolve, milliseconds));
}
async create () {
(self as any).grecaptcha.execute();
let token: string = "";
// the recaptcha API doesn't play nice with Vue
while (!(token = (self as any).grecaptcha.getResponse())) {
await this.sleep(1000);
}
const req = new Request(`${process.env.VUE_APP_API}/tmwa/account`, {
method: "POST",
mode: "cors",
cache: "no-cache",
redirect: "follow",
referrer: "no-referrer",
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
"X-CAPTCHA-TOKEN": token,
},
body: JSON.stringify({
username: this.user.name,
password: this.user.pwd,
email: this.user.email,
}),
});
const raw_response = await fetch(req);
const response: string = await raw_response.text();
switch (raw_response.status) {
// TODO: don't use alerts: embed the error message on the page
case 201:
this.step = 5;
break;
case 400:
self.alert("API: malformed request");
document.location.reload();
break;
case 403:
self.alert("Captcha validation failed.\nPlease try again later");
document.location.reload();
break;
case 409:
this.taken = true;
this.step = 2;
await this.$nextTick();
(this.$refs.user as any).focus();
break;
case 429:
self.alert("Too many requests.\nPlease try again later");
document.location.reload();
break;
case 500:
self.alert("Internal server error.\nPlease try again later");
document.location.reload();
break;
case 502:
self.alert("Couldn't reach the server.\nPlease try again later");
document.location.reload();
break;
default:
self.alert(`Unknown error: ${raw_response.status}`);
document.location.reload();
break;
}
}
}
</script>
<style scoped>
form {
margin-top: 20px;
}
.registration {
& label {
display: block;
&:nth-of-type(1n + 2) {
margin-top: 1em;
}
}
& .pass-box {
position: relative;
&:nth-of-type(1n + 2) {
margin-top: 1em;
}
}
& input {
width: calc(100% - 2ch);
border: 1px solid #2f2e32;
font-size: 15px;
padding: 1ch;
margin-top: 0.6ch;
& + .pass-box {
margin-top: 1em;
}
& + span {
&::after {
content: "👁";
font-family: monospace;
padding: 0 0.5ch 0 0.5ch;
}
position: absolute;
right: -1px;
top: auto;
bottom: 0;
font-size: 1.9em;
cursor: pointer;
}
&[type="text"] + span {
background: rgba(0, 0, 0, 0.2);
}
}
& button {
margin-top: 1em;
width: 100%;
background-color: #34B039;
border: 1px solid #2f2e32;
display: inline-block;
cursor: pointer;
color: #ffffff;
font-size: 15px;
font-weight: bold;
padding: 1ch;
text-decoration: none;
&:hover {
background-color: #2F9E33;
}
}
& > div:nth-of-type(1n + 2) {
margin-top: 30px;
}
& .exposed {
background: rgba(255, 0, 0, 0.1);
border: dashed 6px rgba(255, 0, 0, 0.9);
padding: 1em;
margin: 1em;
animation-name: scary;
animation-duration: 2s;
& a {
display: block;
margin-top: 0.7em;
}
}
& .error {
padding: 1em;
}
}
@keyframes scary {
from {
background-color: rgba(255, 0, 0, 0);
border-color: rgba(255, 0, 0, 0);
}
to {
background-color: rgba(255, 0, 0, 0.1);
border-color: rgba(255, 0, 0, 0.9);
}
}
</style>