feat: registration and css separation

This commit is contained in:
niko 2025-04-25 21:26:27 -04:00
parent 36ce143800
commit 26817d5fbc
No known key found for this signature in database
GPG Key ID: 54046A9008D73455
6 changed files with 327 additions and 147 deletions

12
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "1.0.0",
"license": "AGPL-3.0-only",
"dependencies": {
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
"@nuxt/content": "^3.4.0",
"@nuxt/eslint": "^1.3.0",
"@nuxt/fonts": "^0.11.1",
@ -1270,6 +1271,17 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@hcaptcha/vue3-hcaptcha": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@hcaptcha/vue3-hcaptcha/-/vue3-hcaptcha-1.3.0.tgz",
"integrity": "sha512-IEonS6JiYdU7uy6aeib8cYtMO4nj8utwStbA9bWHyYbOvOvhpkV+AW8vfSKh6SntYxqle/TRwhv+kU9p92CfsA==",
"dependencies": {
"vue": "^3.2.19"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",

View File

@ -13,6 +13,7 @@
"lint:fix": "eslint . --fix"
},
"dependencies": {
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
"@nuxt/content": "^3.4.0",
"@nuxt/eslint": "^1.3.0",
"@nuxt/fonts": "^0.11.1",

137
src/assets/css/auth.css Normal file
View File

@ -0,0 +1,137 @@
header {
margin: 35px 0;
}
.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 {
height: fit-content;
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: absolute;
left: 0;
top: -150px;
width: 100%;
animation: banner-notice 5s;
}
.banner-notice div {
padding: 4px 36px;
border-radius: 5px;
z-index: 3;
}
.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.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 p,
form.account.register div.email,
form.account.register div.buttons {
grid-column: unset;
}
}

View File

@ -85,150 +85,5 @@ async function loginSubmission() {
</template>
<style scoped>
header {
margin: 35px 0;
}
.account-form-wrapper {
height: 75vh;
display: flex;
justify-content: center;
align-content: center;
flex-direction: column;
margin: auto;
width: fit-content;
overflow: hidden;
}
form.account {
height: fit-content;
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: absolute;
left: 0;
top: -150px;
width: 100%;
animation: banner-notice 5s;
}
.banner-notice div {
padding: 4px 36px;
border-radius: 5px;
z-index: 3;
}
.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.register 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;
}
</style>
<style lang="scss">
@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;
}
}
@import "/assets/css/auth.css";
</style>

View File

@ -0,0 +1,140 @@
<script setup lang="ts">
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
import { FetchError } from 'ofetch';
const route = useRoute();
const redirect = computed(() => route.query.redirect);
const loginURI = computed(() => `/account/login${redirect.value ? `?redirect=${redirect.value}` : ''}`);
const registerForm = reactive({ email: '', username: '', mii_name: '', password: '', password_confirm: '' });
const errorMessage = ref<string | null>();
const invisibleHcaptcha = ref<VueHcaptcha | null>(null);
async function registerSubmission() {
if (invisibleHcaptcha.value) {
try {
const hCaptchaResponse = (await invisibleHcaptcha.value.executeAsync()).response;
await $fetch('/api/account/register', {
method: 'POST',
body: { ...registerForm, hCaptchaResponse }
});
if (typeof redirect.value === 'string') {
await navigateTo(redirect.value);
} else {
await navigateTo('/account');
}
} catch (error: unknown) {
if (error instanceof FetchError) {
errorMessage.value = error.statusText;
} else {
if (error === 'challenge-closed') { // Thrown if the captcha is closed, can be safely ignored
return;
}
errorMessage.value = `Error during registration: ${error}`; // TODO: localize
}
setTimeout(() => { // TODO: replace this toast
errorMessage.value = null;
}, 5000);
}
}
}
</script>
<template>
<div>
<div class="account-form-wrapper">
<form
class="account register"
@submit.prevent="registerSubmission"
>
<h2>{{ $t("account.loginForm.register") }}</h2>
<p>{{ $t("account.loginForm.detailsPrompt") }}</p>
<div class="email">
<label for="email">{{ $t("account.loginForm.email") }}</label>
<input
id="email"
v-model="registerForm.email"
name="email"
type="email"
required
>
</div>
<div>
<label for="username">{{ $t("account.loginForm.username") }}</label>
<input
id="username"
v-model="registerForm.username"
name="username"
minlength="6"
maxlength="16"
required
>
</div>
<div>
<label for="mii_name">{{ $t("account.loginForm.miiName") }}</label>
<input
id="mii_name"
v-model="registerForm.mii_name"
name="mii_name"
maxlength="10"
required
>
</div>
<div>
<label for="password">{{ $t("account.loginForm.password") }}</label>
<input
id="password"
v-model="registerForm.password"
name="password"
type="password"
autocomplete="new-password"
required
>
</div>
<div>
<label for="password_confirm">{{ $t("account.loginForm.confirmPassword") }}</label>
<input
id="password_confirm"
v-model="registerForm.password_confirm"
name="password_confirm"
type="password"
autocomplete="new-password"
required
>
</div>
<div class="buttons">
<button type="submit">
{{ $t("account.loginForm.register") }}
</button>
<a
:href="loginURI"
class="register"
>{{ $t("account.loginForm.loginPrompt") }}</a>
</div>
</form>
</div>
<vue-hcaptcha
ref="invisibleHcaptcha"
sitekey="cf3fd74e-93ca-47e6-9fa0-5fc439de06d4"
class="h-captcha"
size="invisible"
/>
<div
v-if="errorMessage"
class="banner-notice error"
>
<div>
<p>{{ errorMessage }}</p>
</div>
</div>
</div>
</template>
<style scoped>
@import "/assets/css/auth.css";
</style>

View File

@ -1 +1,36 @@
// TODO: registration
import { FetchError } from 'ofetch';
// TODO: Drop to interface from type, type is unnecessary for this
type RegisterCCResponse = { // TODO: this is the same as the login response. figure out where we should put types and merge into AuthCCReponse!
refresh_token: string;
access_token: string;
token_type: string;
expires_in: number;
};
export default defineEventHandler(async (event) => {
const body = await readBody(event);
try {
const apiResponse = await $fetch<RegisterCCResponse>(`/v1/register`, {
method: 'POST',
baseURL: useRuntimeConfig(event).apiBase,
body: body
});
setCookie(event, 'refresh_token', apiResponse.refresh_token);
setCookie(event, 'access_token', apiResponse.access_token);
setCookie(event, 'token_type', apiResponse.token_type);
setResponseStatus(event, 200);
event.node.res.end();
} catch (error: unknown) {
if (error instanceof FetchError) {
setResponseStatus(event, error.status, error.data.error);
} else {
setResponseStatus(event, 500);
}
event.node.res.end();
}
});