Skip to content

Commit 109ead0

Browse files
committed
feat(Users): verify email links
1 parent 1e7fc32 commit 109ead0

13 files changed

Lines changed: 431 additions & 48 deletions

File tree

components/registration/VerifyEmail.vue renamed to components/registration/VerifyEmailForm.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@
120120
}
121121
loading.value = true;
122122
try {
123-
await axios.post('/auth/verify-email', {
123+
await axios.post('/auth/verify-email-code', {
124124
email: props.email,
125125
code: code.value,
126126
});

locales/en-us.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,19 @@
107107
"instructor_guide": "You can now use LibreOne to complete your instructor verification. This will allow LibreTexts to give you advanced access to services reserved for educators only.",
108108
"continue_verification": "Continue to Instructor Verification"
109109
},
110+
"email_verification": {
111+
"header": "Email Verification",
112+
"loading": "Checking your verification token...",
113+
"verify_thanks": "Thanks for helping to keep our community safe!",
114+
"success_header": "Email Verified!",
115+
"success_tagline": "Your email has been successfully verified. You can now sign in to LibreOne with your email and password.",
116+
"continue_to_signin": "Continue to Sign In",
117+
"error_header": "Verification Failed",
118+
"error_invalid": "Oops, it looks like your verification token is invalid. If you already entered your six-digit verification code during registration, you can safely ignore this message and sign in to LibreOne.",
119+
"error_expired": "Oops, it looks like your verification token has expired. Please request a new verification email below.",
120+
"resend_verification": "Resend Verification Email",
121+
"resend_success": "Success! A new verification email has been sent to your email address. You can safely close this page now."
122+
},
110123
"password": {
111124
"strength": "Strength",
112125
"short": "Too short",

pages/register/+Page.vue

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@ import {
4242
import AuthForm from "@components/registration/AuthForm.vue";
4343
import { usePageContext } from "@renderer/usePageContext";
4444
import { usePageProps } from "@renderer/usePageProps";
45-
const VerifyEmail = defineAsyncComponent(
46-
() => import("@components/registration/VerifyEmail.vue")
45+
const VerifyEmailForm = defineAsyncComponent(
46+
() => import("@components/registration/VerifyEmailForm.vue")
4747
);
4848
4949
const props = usePageProps<{
@@ -66,7 +66,7 @@ onMounted(() => {
6666
6767
const componentProps = computed(() => {
6868
switch (stage.value) {
69-
case VerifyEmail: {
69+
case VerifyEmailForm: {
7070
return { email: email.value };
7171
}
7272
default: {
@@ -76,7 +76,7 @@ const componentProps = computed(() => {
7676
});
7777
const componentEvents = computed(() => {
7878
switch (stage.value) {
79-
case VerifyEmail: {
79+
case VerifyEmailForm: {
8080
return {};
8181
}
8282
default: {
@@ -92,6 +92,6 @@ const componentEvents = computed(() => {
9292
*/
9393
function handleInitialRegistrationComplete(resEmail: string) {
9494
email.value = resEmail;
95-
stage.value = VerifyEmail;
95+
stage.value = VerifyEmailForm;
9696
}
9797
</script>

pages/verify-email/+Page.vue

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
<template>
2+
<div
3+
class="bg-zinc-100 grid grid-flow-col justify-items-center items-center min-h-screen py-10"
4+
>
5+
<div class="w-11/12 md:w-3/4">
6+
<img
7+
src="@renderer/libretexts_logo.png"
8+
alt="LibreTexts"
9+
class="max-w-xs my-0 mx-auto"
10+
/>
11+
<div
12+
class="bg-white p-6 mt-6 shadow-md shadow-gray-400 rounded-md overflow-hidden"
13+
>
14+
<div aria-live="polite" :aria-busy="loading">
15+
<!-- Loading State -->
16+
<template v-if="loading">
17+
<h1 class="text-center text-3xl font-medium">
18+
{{ $t("email_verification.header") }}
19+
</h1>
20+
<div class="flex items-center justify-center mt-6">
21+
<LoadingIndicator />
22+
<span class="ml-2">{{ $t("email_verification.loading") }}</span>
23+
</div>
24+
<p class="text-xs text-center text-gray-500 mt-4">
25+
{{ $t("email_verification.verify_thanks") }}
26+
</p>
27+
</template>
28+
29+
<!-- Success State -->
30+
<template v-else-if="success">
31+
<h1 class="text-center text-3xl font-medium">
32+
{{ $t("email_verification.success_header") }}
33+
</h1>
34+
<p class="text-center text-gray-700 mt-4">
35+
{{ $t("email_verification.success_tagline") }}
36+
</p>
37+
<a
38+
href="/signin"
39+
class="inline-flex items-center justify-center h-10 bg-primary p-2 mt-6 rounded-md text-white w-full font-medium hover:bg-sky-700 hover:shadow"
40+
>
41+
<span>{{ $t("email_verification.continue_to_signin") }}</span>
42+
<FontAwesomeIcon
43+
icon="fa-solid fa-circle-arrow-right"
44+
class="ml-2"
45+
/>
46+
</a>
47+
</template>
48+
49+
<!-- Invalid/Expired Token State -->
50+
<template v-else>
51+
<h1 class="text-center text-3xl font-medium">
52+
{{ $t("email_verification.error_header") }}
53+
</h1>
54+
<p class="text-center text-gray-700 mt-4" v-if="showResendOption">
55+
{{ $t("email_verification.error_expired") }}
56+
</p>
57+
<p class="text-center text-gray-700 mt-4" v-else>
58+
{{ $t("email_verification.error_invalid") }}
59+
</p>
60+
<p class="text-center text-gray-700 mt-4" v-if="didResend">
61+
{{ $t("email_verification.resend_success") }}
62+
</p>
63+
<ThemedButton
64+
v-if="showResendOption && !didResend"
65+
icon="IconCircleArrowRight"
66+
@click="resendVerificationEmail"
67+
class="mt-6 w-full justify-center"
68+
>
69+
{{ $t("email_verification.resend_verification") }}
70+
</ThemedButton>
71+
</template>
72+
73+
<!-- Error Message -->
74+
<p
75+
class="text-center text-red-600 text-sm mt-4 font-bold"
76+
v-if="error"
77+
>
78+
{{ error }}
79+
</p>
80+
</div>
81+
</div>
82+
</div>
83+
</div>
84+
</template>
85+
86+
<script lang="ts" setup>
87+
import { ref, onMounted } from "vue";
88+
import { useAxios } from "@renderer/useAxios";
89+
import { usePageProps } from "@renderer/usePageProps";
90+
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
91+
import LoadingIndicator from "@components/LoadingIndicator.vue";
92+
import ThemedButton from "../../components/ThemedButton.vue";
93+
94+
const props = usePageProps<{
95+
token: string;
96+
}>();
97+
const axios = useAxios();
98+
99+
const success = ref(false);
100+
const loading = ref(true);
101+
const error = ref<string | null>(null);
102+
const resendUUID = ref<string | null>(null);
103+
const showResendOption = ref(false);
104+
const didResend = ref(false);
105+
106+
async function submitToken() {
107+
try {
108+
loading.value = true;
109+
110+
const result = await axios.post("/auth/verify-email-token", {
111+
token: props.token,
112+
});
113+
114+
success.value = result.data.success === true;
115+
resendUUID.value = result.data.data?.uuid || null;
116+
117+
// Can only resend if the token expired so we got the UUID back
118+
// If it was an invalid token, we won't get a UUID
119+
if (!success.value && resendUUID.value) {
120+
showResendOption.value = true;
121+
}
122+
} catch (e: any) {
123+
console.error(e);
124+
success.value = false;
125+
if (e?.code !== "ERR_BAD_REQUEST") {
126+
error.value = "An error occurred while verifying your email.";
127+
}
128+
} finally {
129+
loading.value = false;
130+
}
131+
}
132+
133+
async function resendVerificationEmail() {
134+
try {
135+
if (!resendUUID.value) return;
136+
137+
loading.value = true;
138+
139+
await axios.post("/auth/resend-verification-email", {
140+
uuid: resendUUID.value,
141+
});
142+
143+
didResend.value = true;
144+
} catch (e) {
145+
console.error("Failed to resend verification email:", e);
146+
error.value =
147+
"An error occurred while resending the verification email. Please contact our Support Center.";
148+
} finally {
149+
loading.value = false;
150+
}
151+
}
152+
153+
// Automatically submit token when component mounts
154+
onMounted(() => {
155+
if (props.token) {
156+
submitToken();
157+
} else {
158+
// No token provided, show error state
159+
loading.value = false;
160+
success.value = false;
161+
}
162+
});
163+
</script>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { PageContextServer } from 'vike/types';
2+
3+
/**
4+
* Reads search parameters provided in the URL and transforms them to component props.
5+
*
6+
* @param pageContext - The current server-side page rendering context.
7+
* @returns New pageContext object with parsed props.
8+
*/
9+
export default async function onBeforeRender(pageContext: PageContextServer) {
10+
const searchParams = pageContext.urlParsed.search;
11+
let token: string | null = null;
12+
if (searchParams.token) {
13+
token = searchParams.token;
14+
}
15+
16+
return {
17+
pageContext: {
18+
pageProps: {
19+
...(token && { token }),
20+
},
21+
},
22+
};
23+
}

0 commit comments

Comments
 (0)