<template> <form class="eppvobhk _monolithic_" :class="{ signing, totpLogin }" @submit.prevent="onSubmit" > <div class="auth _section _formRoot"> <div v-show="withAvatar" class="avatar" :style="{ backgroundImage: user ? `url('${user.avatarUrl}')` : null, marginBottom: message ? '1.5em' : null, }" ></div> <MkInfo v-if="message"> {{ message }} </MkInfo> <div v-if="!totpLogin" class="normal-signin"> <MkInput v-model="username" class="_formBlock" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autofocus required data-cy-signin-username @update:modelValue="onUsernameChange" > <template #prefix>@</template> <template #suffix>@{{ host }}</template> </MkInput> <MkInput v-if="!user || (user && !user.usePasswordLessLogin)" v-model="password" class="_formBlock" :placeholder="i18n.ts.password" type="password" :with-password-toggle="true" autocomplete="current-password" required data-cy-signin-password > <template #prefix ><i class="ph-lock ph-bold ph-lg"></i ></template> <template #caption ><button class="_textButton" type="button" @click="resetPassword" > {{ i18n.ts.forgotPassword }} </button></template > </MkInput> <MkButton class="_formBlock" type="submit" primary :disabled="signing" style="margin: 1rem auto" >{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton > </div> <div v-if="totpLogin" class="2fa-signin" :class="{ securityKeys: user && user.securityKeys }" > <div v-if="user && user.securityKeys" class="twofa-group tap-group" > <p>{{ i18n.ts.tapSecurityKey }}</p> <MkButton v-if="!queryingKey" @click="queryKey"> {{ i18n.ts.retry }} </MkButton> </div> <div v-if="user && user.securityKeys" class="or-hr"> <p class="or-msg">{{ i18n.ts.or }}</p> </div> <div class="twofa-group totp-group"> <p style="margin-bottom: 0"> {{ i18n.ts.twoStepAuthentication }} </p> <MkInput v-if="user && user.usePasswordLessLogin" v-model="password" type="password" :with-password-toggle="true" autocomplete="current-password" required > <template #label>{{ i18n.ts.password }}</template> <template #prefix ><i class="ph-lock ph-bold ph-lg"></i ></template> </MkInput> <MkInput v-model="token" type="text" autocomplete="one-time-code" pattern="^[0-9]{6}$" :spellcheck="false" required > <template #label>{{ i18n.ts._2fa.token }}</template> <template #prefix ><i class="ph-poker-chip ph-bold ph-lg"></i ></template> </MkInput> <MkButton type="submit" :disabled="signing" primary style="margin: 1rem auto auto" >{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton > </div> </div> </div> <div class="social _section"> <a v-if="meta && meta.enableTwitterIntegration" class="_borderButton _gap" :href="`${apiUrl}/signin/twitter`" ><i class="ph-twitter-logo ph-bold ph-lg" style="margin-right: 4px" ></i >{{ i18n.t("signinWith", { x: "Twitter" }) }}</a > <a v-if="meta && meta.enableGithubIntegration" class="_borderButton _gap" :href="`${apiUrl}/signin/github`" ><i class="ph-github-logo ph-bold ph-lg" style="margin-right: 4px" ></i >{{ i18n.t("signinWith", { x: "GitHub" }) }}</a > <a v-if="meta && meta.enableDiscordIntegration" class="_borderButton _gap" :href="`${apiUrl}/signin/discord`" ><i class="ph-discord-logo ph-bold ph-lg" style="margin-right: 4px" ></i >{{ i18n.t("signinWith", { x: "Discord" }) }}</a > </div> </form> </template> <script lang="ts" setup> import Vue3OtpInput from "vue3-otp-input"; import { defineAsyncComponent } from "vue"; import { toUnicode } from "punycode/"; import MkButton from "@/components/MkButton.vue"; import MkInput from "@/components/form/input.vue"; import MkInfo from "@/components/MkInfo.vue"; import { apiUrl, host as configHost } from "@/config"; import { byteify, hexify } from "@/scripts/2fa"; import * as os from "@/os"; import { login } from "@/account"; import { instance } from "@/instance"; import { i18n } from "@/i18n"; let signing = $ref(false); let user = $ref(null); let username = $ref(""); let password = $ref(""); let token = $ref(""); let host = $ref(toUnicode(configHost)); let totpLogin = $ref(false); let credential = $ref(null); let challengeData = $ref(null); let queryingKey = $ref(false); let hCaptchaResponse = $ref(null); let reCaptchaResponse = $ref(null); const updateToken = (value: string) => { token = value.toString(); }; const meta = $computed(() => instance); const emit = defineEmits<{ (ev: "login", v: any): void; }>(); const props = defineProps({ withAvatar: { type: Boolean, required: false, default: true, }, autoSet: { type: Boolean, required: false, default: false, }, message: { type: String, required: false, default: "", }, }); function onUsernameChange() { os.api("users/show", { username: username, }).then( (userResponse) => { user = userResponse; }, () => { user = null; }, ); } function onLogin(res) { if (props.autoSet) { return login(res.i); } } function queryKey() { queryingKey = true; return navigator.credentials .get({ publicKey: { challenge: byteify(challengeData.challenge, "base64"), allowCredentials: challengeData.securityKeys.map((key) => ({ id: byteify(key.id, "hex"), type: "public-key", transports: ["usb", "nfc", "ble", "internal"], })), timeout: 60 * 1000, }, }) .catch(() => { queryingKey = false; return Promise.reject(null); }) .then((credential) => { queryingKey = false; signing = true; return os.api("signin", { username, password, signature: hexify(credential.response.signature), authenticatorData: hexify( credential.response.authenticatorData, ), clientDataJSON: hexify(credential.response.clientDataJSON), credentialId: credential.id, challengeId: challengeData.challengeId, "hcaptcha-response": hCaptchaResponse, "g-recaptcha-response": reCaptchaResponse, }); }) .then((res) => { emit("login", res); return onLogin(res); }) .catch((err) => { if (err === null) return; os.alert({ type: "error", text: i18n.ts.signinFailed, }); signing = false; }); } function onSubmit() { signing = true; console.log("submit"); if (!totpLogin && user && user.twoFactorEnabled) { if (window.PublicKeyCredential && user.securityKeys) { os.api("signin", { username, password, "hcaptcha-response": hCaptchaResponse, "g-recaptcha-response": reCaptchaResponse, }) .then((res) => { totpLogin = true; signing = false; challengeData = res; return queryKey(); }) .catch(loginFailed); } else { totpLogin = true; signing = false; } } else { os.api("signin", { username, password, "hcaptcha-response": hCaptchaResponse, "g-recaptcha-response": reCaptchaResponse, token: user && user.twoFactorEnabled ? token : undefined, }) .then((res) => { emit("login", res); onLogin(res); }) .catch(loginFailed); } } function loginFailed(err) { switch (err.id) { case "6cc579cc-885d-43d8-95c2-b8c7fc963280": { os.alert({ type: "error", title: i18n.ts.loginFailed, text: i18n.ts.noSuchUser, }); break; } case "932c904e-9460-45b7-9ce6-7ed33be7eb2c": { os.alert({ type: "error", title: i18n.ts.loginFailed, text: i18n.ts.incorrectPassword, }); break; } case "e03a5f46-d309-4865-9b69-56282d94e1eb": { showSuspendedDialog(); break; } case "22d05606-fbcf-421a-a2db-b32610dcfd1b": { os.alert({ type: "error", title: i18n.ts.loginFailed, text: i18n.ts.rateLimitExceeded, }); break; } default: { console.log(err); os.alert({ type: "error", title: i18n.ts.loginFailed, text: JSON.stringify(err), }); } } challengeData = null; totpLogin = false; signing = false; } function resetPassword() { os.popup( defineAsyncComponent(() => import("@/components/MkForgotPassword.vue")), {}, {}, "closed", ); } function showSuspendedDialog() { os.alert({ type: "error", title: i18n.ts.yourAccountSuspendedTitle, text: i18n.ts.yourAccountSuspendedDescription, }); } </script> <style lang="scss" scoped> .eppvobhk { > .auth { > .avatar { margin: 0 auto 0 auto; width: 64px; height: 64px; background: var(--accentedBg); background-position: center; background-size: cover; border-radius: 100%; transition: background-image 0.2s ease-in; } } } </style>