summaryrefslogtreecommitdiff
path: root/src/views
diff options
context:
space:
mode:
Diffstat (limited to 'src/views')
-rw-r--r--src/views/About.vue25
-rw-r--r--src/views/AccountRecovery.vue498
-rw-r--r--src/views/Home.vue45
-rw-r--r--src/views/News.vue18
-rw-r--r--src/views/NotFound.vue6
-rw-r--r--src/views/Registration.vue390
-rw-r--r--src/views/Support.vue49
7 files changed, 1031 insertions, 0 deletions
diff --git a/src/views/About.vue b/src/views/About.vue
new file mode 100644
index 0000000..6eeac30
--- /dev/null
+++ b/src/views/About.vue
@@ -0,0 +1,25 @@
+<template>
+ <main class="about">
+ <h1>Description</h1>
+ <p>The Mana World (TMW) is a serious effort to create an innovative free and open source MMORPG. TMW uses 2D graphics and aims to create a large and diverse interactive world. It is licensed under the GPL, making sure this game can't ever run away from you.</p>
+ <p>Explore this large, ever expanding world to defeat monsters, help NPCs and team up with friends as you achieve your goals. Get your weapons, armor and equipment through quests, monsters or crafting. Play mini-games, go on complex investigations or slay powerful bosses. Hang out in town, socialize or attend player organized events. Wear your boots and grab your sword, adventure waits for you!</p>
+
+ <h1>Story</h1>
+ <p>Start in the powerful city-state of Tulimshar before heading out into the vast expanses of the desert. Get powerful and explore the small continent of Argaes where magic is born. In The Mana World you are an adventurer and monster slayer defending the people of the world from the threats created during the Great Quake. Even in the icy heights of Nivalis there is a call for your assistance to keep the world safe and to grow your potential.</p>
+ <p>The Mage Council of Tulimshar has monitored events following the Great Quake and feel that something ominous is spreading throughout The Mana World. Monsters seem to of come out of every shadowy corner and petty dieties has begun to make presence in dark places. The council has made the call and you are just one of the many people that will battle the forces of evil, sending them back to the depths they came from.</p>
+ <p>Be it warrior, archer or mage, you have answered the call from the leaders of the world to fight back the darkness that spread after the Great Quake. Starting in the Tonori desert, you battle your way to forests surrounding Hurnscald or the icy mountains surrounding Nivalis. Monsters have nowhere to hide.</p>
+
+ <h1>Contributors</h1>
+ <p>We are volunteer driven and encourage player participation in development. We have a long history of contributors. We try to make contributing to the game easy.</p>
+ <p>
+ <ul>
+ <li><a href="https://wiki.themanaworld.org/index.php/TMW_Team">TMW Team</a></li>
+ <li><a href="https://gitlab.com/groups/evol/-/group_members">Current contributors</a></li>
+ <li><a href="https://wiki.themanaworld.org/index.php/Dev:Contributors">Past contributors</a></li>
+ </ul>
+ </p>
+ </main>
+</template>
+
+<!-- This content very quickly becomes stales so we might want to (somehow)
+ import from the wiki, or redirect to the wiki entirely -->
diff --git a/src/views/AccountRecovery.vue b/src/views/AccountRecovery.vue
new file mode 100644
index 0000000..f02c2e0
--- /dev/null
+++ b/src/views/AccountRecovery.vue
@@ -0,0 +1,498 @@
+<template>
+ <main>
+ <div v-if="step == 1">
+ <h1>Account Recovery</h1>
+ Use this form if you forgot your username or password.
+ If it matches any account we have on file you will receive a message containing the list of your account usernames
+ along with a password reset link, should you wish to reset your password.
+ </div>
+
+ <div v-if="step == -1">
+ <h1>reCAPTCHA could not be loaded</h1>
+ 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.
+ </div>
+
+ <div v-if="step == 1">
+ <h1>Email address</h1>
+ The email address that was used to register your account(s).
+
+ <div class="error notFound" v-if="notFound">
+ <h2>Not found</h2>
+ We were not able to find any accounts associated with this email address.
+ </div>
+
+ <form @submit.prevent="checkEmail">
+ <label for="email">Enter your email address:</label>
+ <input @input="notFound = false" v-model="user.email" type="email" maxlength="39" id="email" ref="email" placeholder="you@mail.com" required>
+ <button type="submit">Next step &rarr;</button>
+ </form>
+ </div>
+
+ <div v-if="step == 2">
+ <h1>Confirm</h1>
+ <label for="c-email">Email address:</label>
+ <input id="c-email" disabled readonly type="email" :value="user.email" placeholder="(no email)">
+ <button @click="confirm">Recover account</button>
+ </div>
+
+ <div v-if="step == 3">
+ <h1>Recovery process started</h1>
+ An email was sent with the list of your accounts.
+
+ <h1>Next steps</h1>
+ If you wish to reset the password of your accounts, click the provided link in the email you received.<br>
+ This link is only valid once: if you wish to reset more than one password you will have to repeat the process.
+
+ <br><br><br>
+ <h1>Can't find the account you were looking for?</h1>
+ Double-check the email address you entered; You might have used a different address when you created the account or you might have omitted to provide an email address.
+
+ <h1>Still need help?</h1>
+ Feel free to <router-link :to="{ name: 'support' }">contact us</router-link> for further assistance.
+ </div>
+
+ <!-- PART TWO: -->
+
+ <div v-if="step == -2">
+ <h1>Expired link</h1>
+ This password reset link has expired or is invalid.<br>
+ Keep in mind that emailed links are only valid for 60 minutes.
+
+ <h1>Start over</h1>
+ You may try again in 5 minutes: <router-link :to="{ name: 'support' }">account recovery</router-link>
+ </div>
+
+ <div v-if="step == 4">
+ <h1>Username</h1>
+ Your password reset link is <em>only</em> valid for account usernames listed in the email that was sent to you.
+
+ <form @submit.prevent="checkUser">
+ <label for="user">Enter a username:</label>
+ <input 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.length >= 4">Next step &rarr;</button>
+ </form>
+ </div>
+
+ <div v-if="step == 5">
+ <h1>Password</h1>
+ Please choose a new, hard-to-guess password.
+ <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 &rarr;</button>
+ </form>
+ </div>
+
+ <div v-if="step == 6">
+ <h1>Confirm</h1>
+ <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="confirm2">Reset my password</button>
+ </div>
+
+ <div v-if="step == 7">
+ <h1>The deed is done</h1>
+ The password of account <q>{{user.name}}</q> has been reset.
+
+ <h1>Next steps</h1>
+ To start playing, <a href="https://wiki.themanaworld.org/index.php/Downloads">download ManaPlus</a> and select the server <i>The Mana World</i>
+ </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, Component, Prop } from "vue-property-decorator"
+
+@Component
+export default class Recovery extends Vue {
+ step = 1; // no Begin button here
+ notFound = false; // no accounts found
+ visible = false; // password is visible
+ exposed = false; // password has been breached
+ user = {
+ email: "",
+ name: "",
+ pwd: "",
+ pwd2: "",
+ };
+
+ emailToken = "";
+ recaptcha_key = process.env.VUE_APP_RECAPTCHA;
+
+ async mounted () {
+ if (Reflect.has(this.$route.params, "emailToken")) {
+ let token = this.$route.params.emailToken;
+
+ if (/^[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}$/i.test(token)) {
+ this.emailToken = token;
+ this.step = 4;
+ } else {
+ this.step = -2;
+ }
+ }
+
+ // 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 checkEmail () {
+ this.step = Reflect.has(self, "grecaptcha") ? 2 : -1;
+ // XXX: any actual checks needed here?
+ }
+
+ private sleep (milliseconds: number) {
+ return new Promise(resolve => setTimeout(resolve, milliseconds));
+ }
+
+ async confirm () {
+ (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: "PUT",
+ 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({
+ 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 200:
+ case 201:
+ this.step = 3;
+ 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 404:
+ this.notFound = true;
+ this.step = 1;
+ (self as any).grecaptcha.reset();
+ await this.$nextTick();
+ (this.$refs.email as any).focus();
+ break;
+ case 408:
+ this.step = -2;
+ 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;
+ }
+ }
+
+ async checkUser () {
+ // TODO: check if the token is valid for this username
+ this.step = Reflect.has(self, "grecaptcha") ? 5 : -1;
+ 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 = 6;
+ }
+ }
+
+ async confirm2 () {
+ (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: "PUT",
+ 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,
+ code: this.emailToken,
+ }),
+ });
+
+ 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 200:
+ case 201:
+ this.step = 7;
+ 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 404:
+ self.alert("You are unauthorized to reset the password of this account.\nOnly accounts listed in the email you received can be reset.");
+ this.$router.replace({ name: "support" });
+ break;
+ case 408:
+ this.step = -2;
+ 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>
+/*
+TODO: share the stylesheet with Registration (DRY)
+*/
+form {
+ margin-top: 20px;
+}
+
+main {
+ & > h1 + div {
+ margin-top: 30px;
+ }
+
+ & 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>
diff --git a/src/views/Home.vue b/src/views/Home.vue
new file mode 100644
index 0000000..0033939
--- /dev/null
+++ b/src/views/Home.vue
@@ -0,0 +1,45 @@
+<template>
+ <main class="home">
+ <h1>The Mana World Project</h1>
+ <p>The Mana World (TMW) is a serious effort to create an innovative free and open source MMORPG. TMW uses 2D graphics and aims to create a large and diverse interactive world. It is licensed under the GPL, making sure this game can't ever run away from you.</p>
+ <div class="read-more">
+ <a href="#">Read More >></a>
+ </div>
+
+ <h1>Recent News</h1>
+ <News count="1"/>
+ <div class="read-more">
+ <router-link :to="{ name: 'news' }">More News >></router-link>
+ </div>
+ </main>
+</template>
+
+<style scoped>
+.read-more {
+ text-align: right;
+ padding-right: 8px;
+
+ & a, & a:visited {
+ color: #2f2e32;
+ text-decoration: none;
+ font-weight: bold;
+ font-size: 0.8em;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+}
+</style>
+
+<script lang="ts">
+import { Component, Vue } from "vue-property-decorator";
+import News from "@/components/News.vue";
+
+@Component({
+ components: {
+ News,
+ },
+})
+export default class Home extends Vue {}
+</script>
diff --git a/src/views/News.vue b/src/views/News.vue
new file mode 100644
index 0000000..24aa689
--- /dev/null
+++ b/src/views/News.vue
@@ -0,0 +1,18 @@
+<template>
+ <main class="main-content">
+ <h1>News</h1>
+ <News count="Infinity"/>
+ </main>
+</template>
+
+<script lang="ts">
+import { Component, Vue } from "vue-property-decorator";
+import News from "@/components/News.vue";
+
+@Component({
+ components: {
+ News,
+ },
+})
+export default class NewsV extends Vue {}
+</script>
diff --git a/src/views/NotFound.vue b/src/views/NotFound.vue
new file mode 100644
index 0000000..b45a4f2
--- /dev/null
+++ b/src/views/NotFound.vue
@@ -0,0 +1,6 @@
+<template>
+ <main>
+ <h1>Page not found</h1>
+ This page does not exist or has been removed
+ </main>
+</template>
diff --git a/src/views/Registration.vue b/src/views/Registration.vue
new file mode 100644
index 0000000..f2c0ad0
--- /dev/null
+++ b/src/views/Registration.vue
@@ -0,0 +1,390 @@
+<template>
+ <main class="registration">
+ <h1>Account creation</h1>
+ Welcome to The Mana World! With this form you can register for a new game account.<br>
+ Please note that you will also need to down and install ManaPlus, our official game client.
+ <br><br>
+ <button v-if="!step" @click="start">Begin!</button>
+
+ <div v-if="step == -1">
+ <h1>reCAPTCHA could not be loaded</h1>
+ 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.
+ </div>
+
+ <!-- XXX: do we want to add the game rules here? -->
+
+ <div v-if="step == 1">
+ <h1>Email address</h1>
+ We will never give your email address to someone else or send you spam.
+ 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.
+ <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 &rarr;</button>
+ </form>
+ </div>
+
+ <div v-if="step == 2">
+ <h1>Username</h1>
+ Your username is used to log in to the game server. It is never shared with other players: only you see this name.
+ <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>
+ Please choose another username.
+ </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 &rarr;</button>
+ </form>
+ </div>
+
+ <div v-if="step == 3">
+ <h1>Password</h1>
+ Please choose a hard-to-guess password.
+ <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 &rarr;</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>
+ Your account has been successfully created.
+
+ <h1>Next steps</h1>
+ To start playing, <a href="https://wiki.themanaworld.org/index.php/Downloads">download ManaPlus</a> and select the server <i>The Mana World</i>
+ </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>
diff --git a/src/views/Support.vue b/src/views/Support.vue
new file mode 100644
index 0000000..458ffe0
--- /dev/null
+++ b/src/views/Support.vue
@@ -0,0 +1,49 @@
+<template>
+ <main class="support">
+ <h1>Support</h1>
+ <p>Please select your issue below. If you cannot find your issue, contact us.</p>
+
+ <h1>Account problems</h1>
+ <ul>
+ <li><router-link to="/recover/password">I forgot my password</router-link></li>
+ <li><router-link to="/recover/username">I forgot my user name</router-link></li>
+ <li><a href="https://forums.themanaworld.org/viewtopic.php?f=20&t=7559">My account is banned</a></li>
+ <li><a href="https://forums.themanaworld.org/viewtopic.php?f=20&t=6472">My account was compromised</a></li>
+ </ul>
+
+ <h1 id="contact">Contact us</h1>
+ <p>On IRC: <a href="https://forums.themanaworld.org/viewforum.php?f=41" target="_blank" rel="noopener">#themanaworld on Freenode</a></p>
+ <p>On Discord: <a href="https://forums.themanaworld.org/viewforum.php?f=65" target="_blank" rel="noopener">The Mana World server</a></p>
+ <p>On the forums: <a href="https://forums.themanaworld.org/viewforum.php?f=3">Support and Bug reports</a></p>
+
+ <h1>Technical contacts</h1>
+ Legal inquiries: <address>legal@themanaworld.org</address>
+ DMCA takedown requests: <address>legal@themanaworld.org</address>
+ GDPR requests: <address>legal@themanaworld.org</address>
+ Security disclosures:
+ <address>
+ security@themanaworld.org
+ <span v-if="PGP">(PGP: <a :href="`http://pgp.mit.edu/pks/lookup?op=get&search=${PGP}`" rel="noopener">{{PGP}}</a>)</span>
+ </address>
+ </main>
+</template>
+
+<style scoped>
+address {
+ font-family: monospace;
+
+ &:not(:last-of-type) {
+ margin-bottom: 1.5em;
+ }
+}
+</style>
+
+<script lang="ts">
+import Vue from "vue"
+import Component from "vue-class-component"
+
+@Component
+export default class Copyright extends Vue {
+ PGP = process.env.VUE_APP_PGP;
+}
+</script>