feat: implement forgot password + improve captchas

This commit is contained in:
mrjvs
2026-08-15 15:52:21 +02:00
parent cbdecff4a7
commit cb412910f8
6 changed files with 235 additions and 33 deletions

View File

@@ -118,6 +118,12 @@ form.account.register {
margin-bottom: 48px;
}
form.account.register .h-captcha {
grid-column: 1 / span 2;
display: flex;
justify-content: center;
}
form.account.register p,
form.account.register div.email,
form.account.register div.buttons {

View File

@@ -0,0 +1,131 @@
.account-form-wrapper {
height: 80vh;
display: flex;
justify-content: center;
align-content: center;
flex-direction: column;
margin: 15vh auto;
width: fit-content;
overflow: hidden;
}
form.account {
display: block;
padding: 40px 48px;
background-color: var(--bg-shade-2);
color: var(--text-shade-1);
border-radius: 12px;
width: min(480px, 90vw);
box-sizing: border-box;
}
form.account h2 {
margin: 0;
color: var(--text-shade-3);
}
form.account p {
margin: 12px 0;
}
form.account div {
margin-top: 24px;
}
form.account label {
display: block;
margin-bottom: 6px;
text-transform: uppercase;
font-size: 12px;
}
form.account button {
width: 100%;
background: var(--accent-shade-0);
}
form.account a {
text-decoration: none;
display: block;
color: var(--text-shade-1);
text-align: right;
margin: 6px 0;
width: fit-content;
}
form.account a:hover {
color: var(--text-shade-3);
}
form.account a.pwdreset {
margin-left: auto;
font-size: 14px;
}
form.account a.register {
margin: auto;
margin-top: 18px;
}
@keyframes banner-notice {
0% {
top: -150px;
}
20% {
top: 35px;
}
80% {
top: 35px;
}
100% {
top: -150px;
}
}
.banner-notice {
display: flex;
justify-content: center;
position: fixed;
top: -150px;
width: 100%;
animation: banner-notice 5s;
}
.banner-notice div {
padding: 4px 36px;
border-radius: 5px;
z-index: 3;
}
.banner-notice.success div {
background: var(--green-shade-0);
}
.banner-notice.error div {
background: var(--red-shade-1);
}
form.account.register {
display: grid;
grid-template-columns: repeat(2, 1fr);
width: min(780px, 90vw);
column-gap: 24px;
margin-bottom: 48px;
}
form.account.forgot-password div.h-captcha {
grid-column: 1 / span 2;
display: flex;
justify-content: center;
}
form.account.register p,
form.account.register div.email,
form.account.register div.buttons {
grid-column: 1 / span 2;
}
@media screen and (max-width: 720px) {
form.account.register {
grid-template-columns: 1fr;
}
form.account.register div.h-captcha,
form.account.register p,
form.account.register div.email,
form.account.register div.buttons {
grid-column: unset;
}
}

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
const props = defineProps<{
siteKey: string;
theme?: string;
}>();
const captchaEl = useTemplateRef('hcaptcha');
const hcaptchaToken = ref<string | null>(null);
async function getToken(): Promise<string | null> {
// Already has a non-expired token
if (hcaptchaToken.value) {
return hcaptchaToken.value;
}
if (!captchaEl.value) {
return null;
}
try {
const response = await captchaEl.value.executeAsync();
return response.response;
} catch (err) {
console.error('Failed to execute captcha', err);
return null;
}
}
defineExpose({
getToken
});
</script>
<template>
<div>
<VueHcaptcha
ref="hcaptcha"
:sitekey="props.siteKey"
:theme="props.theme"
@verify="(token: string) => (hcaptchaToken = token)"
@expire="hcaptchaToken = null"
/>
</div>
</template>

View File

@@ -1,36 +1,44 @@
<script setup lang="ts">
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
import type { ApiAuthForgotPasswordRequest } from '~~/shared/api-types';
const { t } = useI18n();
const form = reactive({ emailOrUsername: '' });
const errorMessage = ref<string | null>();
const hcaptcha = ref<VueHcaptcha | null>(null);
const successMessage = ref<string | null>();
const captchaRef = useTemplateRef('captcha');
// TODO style this entire page
async function submit() {
errorMessage.value = null;
successMessage.value = null;
try {
const hCaptchaResponse = hcaptcha.value ? (await hcaptcha.value.executeAsync()).response : null;
let captchaResponse: string | null = null;
if (captchaRef.value) {
captchaResponse = await captchaRef.value.getToken();
if (!captchaResponse) {
return;
}
}
await $fetch('/api/auth/forgot-password', {
method: 'POST',
body: {
emailOrPassword: form.emailOrUsername,
captchaResponse: hCaptchaResponse ?? undefined
captchaResponse: captchaResponse ?? undefined
} satisfies ApiAuthForgotPasswordRequest
});
alert('Success - check your inbox');
await navigateTo('/');
// Success
form.emailOrUsername = '';
successMessage.value = 'An email has been sent.';
setTimeout(() => {
successMessage.value = null;
}, 5000);
} catch (error: unknown) {
if (error === 'challenge-closed') {
// Thrown if the captcha is closed, can be safely ignored
return;
}
const err = getApiError(error);
errorMessage.value = err.code;
setTimeout(() => { // TODO: replace this toast
setTimeout(() => {
errorMessage.value = null;
}, 5000);
}
@@ -41,31 +49,42 @@ async function submit() {
<div>
<div class="account-form-wrapper">
<form
class="account register"
class="account forgot-password"
@submit.prevent="submit"
>
<h2>Forgot password</h2>
<h2>{{ t('account.forgotPassword.header') }}</h2>
<p>{{ t('account.forgotPassword.sub') }}</p>
<div>
<label for="input">{{ t('account.forgotPassword.input') }}</label>
<input
id="input"
v-model="form.emailOrUsername"
type="text"
required
>
</div>
<vue-hcaptcha
<Captcha
v-if="$config.public.hcaptchaSiteKey"
ref="hcaptcha"
:sitekey="$config.public.hcaptchaSiteKey"
ref="captcha"
class="h-captcha"
:site-key="$config.public.hcaptchaSiteKey"
theme="dark"
/>
<div class="buttons">
<button type="submit">
Send
{{ t('account.forgotPassword.submit') }}
</button>
</div>
</form>
</div>
<div
v-if="successMessage"
class="banner-notice success"
>
<div>
<p>{{ successMessage }}</p>
</div>
</div>
<div
v-if="errorMessage"
class="banner-notice error"
@@ -78,5 +97,5 @@ async function submit() {
</template>
<style scoped>
@import "/assets/css/auth.css";
@import "~/assets/css/forgot-password.css";
</style>

View File

@@ -97,5 +97,5 @@ async function loginSubmission() {
</template>
<style scoped>
@import "/assets/css/auth.css";
@import "~/assets/css/auth.css";
</style>

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
import type { ApiAuthRegisterRequest } from '~~/shared/api-types';
const route = useRoute();
@@ -19,11 +18,17 @@ const loginURI = computed(() => {
const registerForm = reactive({ email: '', username: '', mii_name: '', password: '', password_confirm: '' });
const errorMessage = ref<string | null>();
const invisibleHcaptcha = ref<VueHcaptcha | null>(null);
const captchaRef = useTemplateRef('captcha');
async function registerSubmission() {
try {
const hCaptchaResponse = invisibleHcaptcha.value ? (await invisibleHcaptcha.value.executeAsync()).response : null;
let captchaResponse: string | null = null;
if (captchaRef.value) {
captchaResponse = await captchaRef.value.getToken();
if (!captchaResponse) {
return;
}
}
const res = await $fetch('/api/auth/register', {
method: 'POST',
@@ -32,7 +37,7 @@ async function registerSubmission() {
miiName: registerForm.mii_name,
password: registerForm.password,
username: registerForm.username,
captchaResponse: hCaptchaResponse ?? undefined
captchaResponse: captchaResponse ?? undefined
} satisfies ApiAuthRegisterRequest
});
auth.set({
@@ -41,11 +46,6 @@ async function registerSubmission() {
});
await authUtils.safelyRedirectAfterLogin(redirect.value);
} catch (error: unknown) {
if (error === 'challenge-closed') {
// Thrown if the captcha is closed, can be safely ignored
return;
}
const err = getApiError(error);
errorMessage.value = err.code;
setTimeout(() => { // TODO: replace this toast
@@ -119,11 +119,11 @@ async function registerSubmission() {
required
>
</div>
<vue-hcaptcha
<Captcha
v-if="$config.public.hcaptchaSiteKey"
ref="hcaptcha"
:sitekey="$config.public.hcaptchaSiteKey"
ref="captcha"
class="h-captcha"
:site-key="$config.public.hcaptchaSiteKey"
theme="dark"
/>
<div class="buttons">